diff --git a/.gitignore b/.gitignore index 3c7ad902a..f4a66720c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ *.snap .flatpak-builder +__pycache__/ +*.pyc # Qt build output *.user # doxygen Doxyfile output diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index d995a4411..9a186440c 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -30,14 +30,20 @@ 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_diff` | **what an edit actually changed** — moves with deltas, adds, removes, relabels, conductor field changes | +| `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 | | `qet_export` | run a headless export (pdf, png, svg, bom, cables, wires, wiring, nets, links, info) | +| `qet_edit` | **change a project** — place, move, rotate, label, wire, number, cross-reference, add text, shapes and images, restyle a symbol's text fields, delete; then diff the result | +| `qet_element_build` | **author a `.elmt`** — draw a new symbol, with terminals to wire it by | +| `qet_project_new` | **start from nothing** — an empty project with a title and folios | +| `qet_element_search` | **find a symbol** in a collection by name (any language), type or terminal count | +| `qet_check` | **design-rule checks** — duplicate labels, unlabelled masters, unnumbered conductors, empty folios | +| `qet_query` | **ask the project database** — read-only SQL over the views and tables | -Only `qet_export` launches QElectroTech. Everything else parses the file -directly, which is faster, needs no display, and cannot be confused by a -dialog. +`qet_export` and `qet_edit` launch QElectroTech. Everything else parses the +file directly, which is faster, needs no display, and cannot be confused by +a dialog. ## Running it @@ -79,6 +85,74 @@ Register it with an MCP client, for example: Four elements moved by one uniform delta; nothing was relabelled. That is the answer a screenshot gave wrongly. +**Draw something, and check it landed** + +```json +{"name": "qet_edit", "arguments": { + "binary": "/path/to/qelectrotech", + "project": "in.qet", "output": "out.qet", + "elements_dir": "/path/to/qelectrotech/elements", + "operations": [ + {"op": "add_folio", "id": "f"}, + {"op": "set_folio_title", "folio": "$f", "title": "Starter"}, + {"op": "add_element", "id": "k1", "folio": "$f", "path": "common://.../coil.elmt", "x": 100, "y": 100}, + {"op": "add_element", "id": "k2", "folio": "$f", "path": "common://.../coil.elmt", "x": 320, "y": 100}, + {"op": "add_conductor", "folio": "$f", "from": "$k1", "from_terminal": 0, "to": "$k2", "to_terminal": 0}, + {"op": "set_conductor", "folio": "$f", "element": "$k1", "terminal": 0, "property": "num", "value": "W7"}, + {"op": "set_label", "folio": "$f", "element": "$k1", "label": "KM1"} + ]}} +``` + +An op that creates something takes an `"id"`; later ops name it as `"$id"`. +Terminals are addressed by index — top to bottom, then left to right, **not** +the order the `.elmt` lists them. `qet_element_info` and `qet_element_search` +both report that index order. The answer carries a per-operation result +*and* a `qet_diff`, because "addConductor → true" says the call was +accepted, not that the file came out right: + +```json +"diff": {"elements": {"before": 11, "after": 13, "added": ["{0aa3…}", "{6f63…}"]}, + "conductors": {"before": 47, "after": 48, "added": ["4:{0aa3…}/{2904…}--{6f63…}/{2904…}"], + "removed": []}} +``` + +**Draw a symbol that does not exist yet** + +```json +{"name": "qet_element_build", "arguments": { + "output": "/path/to/collection/99_custom/my_resistor.elmt", + "names": {"en": "Test resistor", "fr": "Résistance de test"}, + "parts": [ + {"type": "rect", "x": -10, "y": -20, "width": 20, "height": 40}, + {"type": "line", "x1": 0, "y1": -30, "x2": 0, "y2": -20}, + {"type": "line", "x1": 0, "y1": 20, "x2": 0, "y2": 30}, + {"type": "text", "x": 14, "y": -4, "text": "R"} + ], + "terminals": [{"x": 0, "y": -30, "orientation": "n", "name": "1"}, + {"x": 0, "y": 30, "orientation": "s", "name": "2"}]}} +``` + +Then place it with `qet_edit` like any catalogue element. Unlike a +project, a `.elmt` is not rewritten by QElectroTech on a round trip, so +generating one here is safe in a way that generating a `.qet` would not +be — there is no `toXml()` waiting to drop what this writer did not know +to emit. + +**Ask a question the XML cannot answer** + +```json +{"name": "qet_query", "arguments": { + "binary": "/path/to/qelectrotech", "project": "industrial.qet", + "sql": "SELECT label, COUNT(*) AS n FROM element_nomenclature_view WHERE label <> '' GROUP BY label HAVING n > 1 ORDER BY n DESC"}} +``` + +```json +"rows": [{"label": "V6", "n": 7}, {"label": "V5", "n": 6}, {"label": "V4", "n": 6}] +``` + +Duplicate element labels in a shipped example — a design-rule question, +answered by the database that already knew it. + **How much of a corpus uses a field?** ```json @@ -92,14 +166,37 @@ the answer a screenshot gave wrongly. Across the shipped examples: 3190 conductors, not one with a cable value. +## Testing + +```bash +python3 test_qet_mcp.py # unit + protocol, no QElectroTech needed +QET_BINARY=/path/to/qelectrotech \ +QET_ELEMENTS=/path/to/qelectrotech/elements \ +QET_EXAMPLES=/path/to/qelectrotech/examples \ + python3 test_qet_mcp.py # everything +``` + +115 tests in three layers: unit (validation, script generation, the terminal +order rule, the diff, the part schema), the real stdio transport, and +integration against a built QElectroTech. Several exist because the +behaviour they pin was once wrong and looked right, and say so in their +docstrings. To check the suite itself rather than trust it, each of those +bugs was reintroduced in turn and the suite confirmed to fail: ten in the +Python, plus the hang guard on `addConductor` and the database refresh in +`ConductorCreator` in the C++. + ## Notes and limits -- **The project database is not reachable from outside the application.** - `projectDataBase::newQuery()` and `isReadOnlySelect()` are C++-internal - and the JavaScript scripting API exposes no SQL binding, so structural - queries here are done over the XML. A `--query` CLI verb, or a scripting - binding, would let this server expose the guarded read-only SQL surface - instead, and would be a better foundation. +- **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 — + `element_nomenclature_view`, `project_summary_view`, `wiring_list_view` + — which exist to be queried; the underlying tables are how the cache is + arranged today and a column may move. Call `qet_query` with no `sql` to + list both. Only `SELECT` and `WITH` are accepted, which is the rule + QElectroTech applies to its own custom-query box, not one invented here. + An empty result and a failed query are told apart: `row_count` 0 with no + `error` means nothing matched, and a typo'd column name says so. - **`qet_export` isolates its launch.** SingleApplication keys its socket on `applicationFilePath()`, so a second launch of the same binary path forwards its request to an already-running instance and returns *that* @@ -115,16 +212,64 @@ Across the shipped examples: 3190 conductors, not one with a cable value. its ends with `terminal1`/`terminal2`, and the project format has two schemes: folio-scoped integer ids in older files, terminal-definition uuids plus `element1`/`element2` in newer ones. The integer ids are - **renumbered on every save**, so keying on them made all 47 conductors of - an untouched `ArduinoLCD.qet` read as 29 removed and 29 re-added the - moment the other side had been through QElectroTech. Ends are now keyed - by owning element uuid plus terminal, which is stable across a save: - measured at 0 colliding keys over 3190 conductors in the 24 shipped - examples, and 0 churn on a re-saved but otherwise untouched project. - Where an element predates persisted uuids the end cannot be resolved and - keeps a `#`-marked unstable key; the diff then reports `unstable_keys` - and says so rather than pretending to be comparable. + **renumbered on every save**, so keying on them — which this tool did at + first — made all 47 conductors of an untouched folio read as removed and + re-added the moment the other side had been through QElectroTech, which + is exactly what `qet_edit` produces. They are now keyed by owning element + uuid plus terminal, which is stable across a save: measured at 0 colliding + keys over 3190 conductors in the 24 shipped examples, and 0 churn on a + no-op edit. Where an element predates persisted uuids the end cannot be + resolved and keeps a `#`-marked unstable key; the diff then reports + `unstable_keys` and says so rather than pretending to be comparable. +- **Texts, shapes and images have no uuid**, so `qet_diff` cannot say "the same + text, edited": an edited text reads as the old one removed and a new one + added, both shown. Shapes and images are keyed by position, so a restyle + or rescale *is* reported as a change to that item, but a move reads as a + removal plus an addition. The folio `version` attribute is left out of the + comparison on purpose: QElectroTech rewrites it on every save, and + including it made every folio of any re-saved project look edited. - **Elements** written before persisted uuids fall back to a positional key, - which makes a move in such a file read as a remove plus an add rather - than as a move. -- Read-only by design. Nothing here writes to a project. + which makes a move in such a file read as a remove plus an add. +- **`qet_edit` needs a build whose scripting API carries the drawing verbs.** + Against an older one it reports exactly which methods are missing and + changes nothing. `addElement` and the move/delete verbs shipped with the + scripting API; `addConductor`, `rotateElement`, `setElementLabel`, + `setElementInfo` and `setFolioTitle` are newer. +- **`elements_dir` is not optional for `common://` paths.** The sandboxed + run has its own empty HOME, so QElectroTech falls back to the compiled-in + collection path, which on a machine that never ran `make install` does not + exist. The only symptom is `addElement` reporting that a file plainly + present "does not resolve to an element". An absolute `.elmt` path works + without it. +- **`set_conductor` changes the whole potential, not one segment.** That is + what the application does — a wire number describes a potential — so name + a terminal carrying exactly one conductor and the change reaches every + conductor electrically joined to it. A terminal several conductors meet + at names none of them and is refused, so address a potential from one of + its leaves. Property names are the file's own, so `qet_conductors` reads + back exactly what was set. +- **`link_elements` takes a folio for each end**, because a master and its + slave are normally on different folios. Whether a pair may be linked is + decided by QElectroTech's own `isLinkable()`, so a script cannot make a + link the GUI would refuse. +- **An element must live inside a collection to be placeable.** This is + not about the path syntax: an absolute `.elmt` path works, but only if + the file sits under a directory QElectroTech knows as a collection. + Write it under the tree you pass as `elements_dir` and `qet_edit` can + place it, by absolute path or as `common://…`; write it anywhere else + and `add_element` reports only "does not resolve to an element". +- **`qet_element_build` computes the `.elmt` size header, and checks it.** + `width`/`height`/`hotspot_x`/`hotspot_y` relate to the drawing by a + containment constraint, not a formula — the declared box runs from + `(-hotspot_x, -hotspot_y)` to `(width - hotspot_x, height - hotspot_y)` + and the drawing must fit inside it. The shipped collection shows authors + picking their own margins (one element pads 2 units left and 3 right, + another 8 and 2), so there is no convention to copy, only an invariant + to satisfy. A drawing that escaped its box is the classic way a + hand-written element renders clipped in the collection panel while + looking fine in XML. +- **QElectroTech interrupts a script at 30 s** of its own accord, separately + from this tool's `timeout`. A very long operation list will hit that + first. +- **`qet_edit` never writes the input.** It saves to a separate file and + diffs the two, so the original is always the thing the diff is against. diff --git a/misc/qet-mcp/__pycache__/qet_mcp.cpython-314.pyc b/misc/qet-mcp/__pycache__/qet_mcp.cpython-314.pyc deleted file mode 100644 index bb43f416c..000000000 Binary files a/misc/qet-mcp/__pycache__/qet_mcp.cpython-314.pyc and /dev/null differ diff --git a/misc/qet-mcp/fixtures/plc_master_test.elmt b/misc/qet-mcp/fixtures/plc_master_test.elmt new file mode 100644 index 000000000..39a140262 --- /dev/null +++ b/misc/qet-mcp/fixtures/plc_master_test.elmt @@ -0,0 +1,44 @@ + + + + ملف KA بمحتفظ مغناطيسي + Bobina + Bistabilní remanentní relé + Remanenzrelais + Πηνίο με μανδάλωση + Coil + Bobina KA de remanencia + Bobine KA à rémanence + Tekercs + Bobina + spoel remanent + Spoel KA remanent + Cewka przekaźnika remanencyjnego + Bobina + Обмотка + 剩磁保持线圈 + + + plc + + Author: The QElectroTech team +License: see http://qelectrotech.org/wiki/doc/elements_license + + + + + A2 + + + A1 + + + + + label + + + + + + diff --git a/misc/qet-mcp/fixtures/plc_slave_test.elmt b/misc/qet-mcp/fixtures/plc_slave_test.elmt new file mode 100644 index 000000000..c5ecac2b3 --- /dev/null +++ b/misc/qet-mcp/fixtures/plc_slave_test.elmt @@ -0,0 +1,16 @@ + + + + PLC Slave Test + + + plc + NO + 1 + + + + + + + diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 3e1966d02..11b30094c 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -52,6 +52,7 @@ No third-party dependencies — the MCP SDK is not assumed to be present. from __future__ import annotations import json +import re import os import shutil import subprocess @@ -148,8 +149,8 @@ def _terminal_index(diagram: ET.Element) -> dict: save and terminal1="34" terminal2="15" after one. Keying a conductor on that pair, which this tool used to do, made every conductor in the file read as removed-and-re-added whenever the "after" side had been through - QElectroTech -- which is the common case for "what did that edit change", - so the conductor half of the diff was noise precisely when it was needed. + QElectroTech -- which is exactly the case qet_edit produces, so the + conductor half of the diff was noise precisely when it was needed. So resolve each id to (owning element uuid, terminal position and orientation inside that element). Element uuids are persisted and @@ -288,6 +289,144 @@ def tool_conductors(path: str, folio: int | None = None, "conductors": rows[:limit]} +# No "version": that attribute is the file-format stamp QElectroTech rewrites +# on every save, so diffing it made every folio of any re-saved project look +# edited, and it is not something a script can set (see setFolioProperty). +_FOLIO_FIELDS = ("title", "author", "plant", "locmach", "indexrev", + "folio", "filename", + # the frame: attribute names as the file writes them + "cols", "colsize", "rows", "rowsize", "displaycols", "displayrows", + "titleblocktemplate") + + +def _plain_text(html: str) -> str: + """The visible text of an independent text's HTML, which is what a + person means by "the text". The file stores a whole HTML document.""" + import re + body = re.search(r"]*>(.*)", html or "", re.S) + inner = body.group(1) if body else (html or "") + inner = re.sub(r"<[^>]+>", "", inner) + for a, b in (("<", "<"), (">", ">"), ("&", "&"), (""", '"'), ("'", "'")): + inner = inner.replace(a, b) + return " ".join(inner.split()) + + +def _extras(root: ET.Element) -> dict: + """Everything a folio holds besides elements and conductors. + + Independent texts, shapes and images have no uuid, so nothing here can + say "this is the same text, edited". A change to one therefore reads as + the old one removed and a new one added, with both shown -- accurate, + if less tidy than a move. Position is the identity they have. + """ + folios, texts, shapes, images = {}, {}, {}, {} + for n, d in _folios(root): + folios[n] = {f: d.get(f, "") for f in _FOLIO_FIELDS} + for t in d.iter("input"): + key = (n, t.get("x", ""), t.get("y", ""), _plain_text(t.get("text", ""))) + texts[key] = {"rotation": t.get("rotation", "0"), + "font": t.get("font", ""), "color": t.get("color", "")} + for sh in d.iter("shape"): + pen, brush = sh.find("pen"), sh.find("brush") + key = (n, sh.get("type", ""), sh.get("x1", ""), sh.get("y1", ""), + sh.get("x2", ""), sh.get("y2", "")) + shapes[key] = { + "line_color": pen.get("color", "") if pen is not None else "", + "line_style": pen.get("style", "") if pen is not None else "", + "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")} + for im in d.iter("image"): + key = (n, im.get("x", ""), im.get("y", "")) + images[key] = {"scale": im.get("size", ""), "rotation": im.get("rotation", "")} + + element_texts = {} + for n, d in _folios(root): + for el in d.iter("element"): + uuid = el.get("uuid", "") + seen = {} + for t in el.iter("dynamic_elmt_text"): + src = t.get("text_from", "") + what = (t.findtext("info_name") if src == "ElementInfo" + else t.findtext("composite_text") if src == "CompositeText" + else t.findtext("text")) or "" + base = (uuid, src, what) + seen[base] = seen.get(base, 0) + 1 + fs = (t.get("font", "").split(",") + ["", ""])[1] + element_texts[base + (seen[base],)] = { + "x": t.get("x", ""), "y": t.get("y", ""), "size": fs, + "frame": t.get("frame", ""), "rotation": t.get("rotation", ""), + "width": t.get("text_width", ""), + "shows": t.findtext("text") or ""} + + strips = {} + for st in root.iter("terminal_strip"): + data = st.find("terminal_strip_data") + if data is None: + continue + info = {i.get("name"): (i.text or "") for i in data.iter("information")} + strips[data.get("uuid", "")] = { + "installation": info.get("installation", ""), + "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} + + +def _diff_keyed(a: dict, b: dict, label) -> dict: + """added / removed / changed for two dicts keyed by identity.""" + changed = [] + for k in sorted(set(a) & set(b), key=str): + delta = {f: [a[k][f], b[k][f]] for f in a[k] if a[k][f] != b[k].get(f)} + if delta: + changed.append({"item": label(k), "changed": delta}) + return {"before": len(a), "after": len(b), + "added": [label(k) for k in sorted(set(b) - set(a), key=str)][:50], + "removed": [label(k) for k in sorted(set(a) - set(b), key=str)][:50], + "changed": changed[:50]} + + +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["texts"] = _diff_keyed(a["texts"], b["texts"], + lambda k: {"folio": k[0], "x": k[1], "y": k[2], "text": k[3]}) + out["shapes"] = _diff_keyed(a["shapes"], b["shapes"], + lambda k: {"folio": k[0], "type": k[1], + "from": [k[2], k[3]], "to": [k[4], k[5]]}) + out["images"] = _diff_keyed(a["images"], b["images"], + lambda k: {"folio": k[0], "x": k[1], "y": k[2]}) + # 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["terminal_strips"] = _diff_keyed( + a["strips"], b["strips"], + lambda k: (lambda v: f"{v['installation']} {v['location']} {v['name']}".strip())( + (b["strips"].get(k) or a["strips"].get(k)))) + return out + + def tool_diff(before: str, after: str) -> dict: """Structural diff of two .qet files. @@ -372,6 +511,7 @@ def tool_diff(before: str, after: str) -> dict: "changed_count": len(conductor_changes), **unstable, }, + **_diff_extras(_root(before), _root(after)), } @@ -421,14 +561,41 @@ def tool_scan(directory: str, tag: str = "conductor", } +def _terminals_in_index_order(terminal_nodes) -> tuple: + """Order an element's terminals the way QElectroTech indexes them. + + Not file order. Element::parseTerminal() re-sorts the terminals every + time it adds one, top to bottom and then left to right, on each + terminal's local (y, x) -- Terminal::dockConductor() is mapToScene() of + its position, evaluated while the element still sits unrotated at the + origin. So the terminal a script reaches as index 0 is the topmost one, + whatever order the .elmt lists them in: bobine_ka_a_remanence.elmt + writes A2 (y=20) before A1 (y=-20), and add_conductor's index 0 is A1. + Getting this wrong wires the wrong end of a coil and nothing complains. + + Returns (nodes in index order, ambiguous). Two terminals at the same + point tie, and the C++ sort is not stable, so which one is index 0 is + not defined; ambiguous says so instead of pretending. + """ + def key(t): + try: + return (float(t.get("y", 0)), float(t.get("x", 0))) + except ValueError: + return (0.0, 0.0) + ordered = sorted(terminal_nodes, key=key) + keys = [key(t) for t in ordered] + return ordered, len(keys) != len(set(keys)) + + def tool_element_info(path: str) -> dict: """Introspect a .elmt: names, terminals, and which info fields it carries.""" root = _root(path) names = {n.get("lang"): (n.text or "") for n in root.iter("name")} - terminals = [{"x": t.get("x"), "y": t.get("y"), + ordered, ambiguous = _terminals_in_index_order(list(root.iter("terminal"))) + terminals = [{"index": i, "x": t.get("x"), "y": t.get("y"), "orientation": t.get("orientation"), "name": t.get("name", ""), "type": t.get("type", "")} - for t in root.iter("terminal")] + for i, t in enumerate(ordered)] info_fields = sorted({(i.text or "").strip() for i in root.iter("info_name") if (i.text or "").strip()}) parts = {} @@ -441,12 +608,18 @@ def tool_element_info(path: str) -> dict: "width": root.get("width"), "height": root.get("height"), "names": names, "terminal_count": len(terminals), "terminals": terminals, + "terminal_order": "index order: top to bottom then left to right, " + "not the file's order" + ( + "; two terminals share a point, so their relative " + "index is undefined" if ambiguous else ""), "info_fields": info_fields, "parts": parts, } -def _run_qet(binary: str, args: list[str], timeout: int = 180) -> dict: +def _run_qet(binary: str, args: list[str], timeout: int = 180, + elements_dir: str | None = None, + script: str | None = None, tail: int = 4000) -> dict: """Launch QElectroTech headlessly, carrying the known launch traps. SingleApplication keys its socket on applicationFilePath(), so a second @@ -454,6 +627,22 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180) -> dict: instance and returns THAT process's answer with no error. Copying the binary to a unique path gives this run its own socket. A symlink will not do: applicationFilePath() resolves it back. + + The sandbox HOME that isolation buys also costs something, and it is + not obvious: with no settings file, QETApp::commonElementsDir() falls + back to the compiled-in QET_COMMON_COLLECTION_PATH, which on a machine + that has never run `make install` does not exist. Every "common://..." + path then fails to resolve and the only symptom is addElement() + reporting "does not resolve to an element" for a file that is plainly + there. elements_dir writes the one setting that fixes it. The file name + is not free-choice: QSettings derives it from the organisation and + application names main.cpp sets before this branch runs, so it must be + QElectroTech/QElectroTech.conf and nothing else. + + script, when given, is written into the sandbox and passed to --run. + It lives inside the temporary directory so it cannot collide with a + concurrent call, and it is returned to the caller on failure, because a + generated script nobody can see is not debuggable. """ src = Path(binary).expanduser() if not src.is_file() or not os.access(src, os.X_OK): @@ -465,6 +654,19 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180) -> dict: home = sandbox / "home" (home / ".config").mkdir(parents=True) (home / ".local" / "share").mkdir(parents=True) + if elements_dir: + coll = Path(elements_dir).expanduser() + if not coll.is_dir(): + raise ValueError(f"no such elements directory: {coll}") + cfg = home / ".config" / "QElectroTech" + cfg.mkdir(parents=True, exist_ok=True) + (cfg / "QElectroTech.conf").write_text( + "[elements-collections]\n" + f"common-collection-path={coll}\n", encoding="utf-8") + if script is not None: + script_path = sandbox / "qet-mcp-edit.js" + script_path.write_text(script, encoding="utf-8") + args = ["--run", str(script_path), *args] env = dict(os.environ, HOME=str(home), XDG_CONFIG_HOME=str(home / ".config"), @@ -478,7 +680,7 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180) -> dict: "hint": "a modal dialog during load will hang a headless " "run; check the project's format version"} return {"ok": p.returncode == 0, "exit_code": p.returncode, - "stdout": p.stdout[-4000:], "stderr": p.stderr[-4000:]} + "stdout": p.stdout[-tail:], "stderr": p.stderr[-tail:]} def tool_export(binary: str, project: str, format: str, output: str, @@ -505,6 +707,1246 @@ def tool_export(binary: str, project: str, format: str, output: str, return result +# -------------------------------------------------------------------------- +# qet_element_build: author a .elmt definition +# -------------------------------------------------------------------------- + +# Derived from the 6,918 shipped elements rather than from documentation: +# these are the attributes each part tag actually carries. Everything not +# listed here is refused, so a typo becomes an error instead of an +# attribute QElectroTech silently ignores. +PART_SCHEMA = { + "line": {"required": ("x1", "y1", "x2", "y2"), + "optional": ("end1", "end2", "length1", "length2")}, + "rect": {"required": ("x", "y", "width", "height"), "optional": ("rx", "ry")}, + "ellipse": {"required": ("x", "y", "width", "height"), "optional": ()}, + "circle": {"required": ("x", "y", "diameter"), "optional": ()}, + "arc": {"required": ("x", "y", "width", "height", "start", "angle"), + "optional": ()}, + "polygon": {"required": ("points",), "optional": ("closed",)}, + "text": {"required": ("x", "y", "text"), "optional": ("size", "rotation", "color")}, +} + +DEFAULT_STYLE = "line-style:normal;line-weight:normal;filling:none;color:black" + +TERMINAL_ORIENTATIONS = ("n", "s", "e", "w") + +# As used in the collection. "thumbnail" is included because it is the +# second most common value, not because this tool can build a good one. +LINK_TYPES = ("simple", "thumbnail", "master", "slave", "terminal", + "next_report", "previous_report") + + +def _f(value, where: str) -> float: + try: + return float(value) + except (TypeError, ValueError): + raise ValueError(f"{where}: expected a number, got {value!r}") + + +def _part_extent(kind: str, part: dict) -> list: + """The x,y points a part reaches, for the bounding box.""" + g = lambda k: _f(part[k], f"{kind}.{k}") + if kind == "line": + return [(g("x1"), g("y1")), (g("x2"), g("y2"))] + if kind in ("rect", "ellipse", "arc"): + x, y, w, h = g("x"), g("y"), g("width"), g("height") + return [(x, y), (x + w, y + h)] + if kind == "circle": + x, y, d = g("x"), g("y"), g("diameter") + return [(x, y), (x + d, y + d)] + if kind == "polygon": + return [(_f(px, "polygon point"), _f(py, "polygon point")) + for px, py in part["points"]] + if kind == "text": + return [(g("x"), g("y"))] + return [] + + +def _element_geometry(parts: list, terminals: list) -> dict: + """Bounding box, then a declared box that contains it. + + The .elmt header carries width/height/hotspot_x/hotspot_y, and the + relationship to the drawing is a containment constraint rather than a + formula: the declared box runs from (-hotspot_x, -hotspot_y) to + (width - hotspot_x, height - hotspot_y) in the element's own + coordinates, and the drawing has to fit inside it. Checked against the + shipped collection, where authors chose their own margins -- one + element pads 2 units on the left and 3 on the right, another 8 and 2 -- + so there is nothing to copy, only an invariant to satisfy. + + Sizes are rounded out to multiples of 10, which is what every element + sampled from the collection uses and what keeps terminals on the grid. + """ + points = [] + for part in parts: + points += _part_extent(part["type"], part) + for t in terminals: + points.append((_f(t["x"], "terminal.x"), _f(t["y"], "terminal.y"))) + if not points: + raise ValueError("an element needs at least one part or terminal") + + min_x = min(x for x, _ in points) + max_x = max(x for x, _ in points) + min_y = min(y for _, y in points) + max_y = max(y for _, y in points) + + import math + pad = 5.0 + hotspot_x = int(math.ceil((-min_x + pad) / 10.0) * 10) + hotspot_y = int(math.ceil((-min_y + pad) / 10.0) * 10) + width = int(math.ceil((max_x + hotspot_x + pad) / 10.0) * 10) + height = int(math.ceil((max_y + hotspot_y + pad) / 10.0) * 10) + + # The invariant, asserted rather than trusted: an element whose drawing + # escapes its declared box is the classic way a hand-written .elmt + # renders clipped in the collection panel while looking fine in XML. + if not (-hotspot_x <= min_x and max_x <= width - hotspot_x + and -hotspot_y <= min_y and max_y <= height - hotspot_y): + raise ValueError( + f"internal error: declared box ({-hotspot_x}, {-hotspot_y}) to " + f"({width - hotspot_x}, {height - hotspot_y}) does not contain the " + f"drawing ({min_x}, {min_y}) to ({max_x}, {max_y})") + + return {"width": width, "height": height, + "hotspot_x": hotspot_x, "hotspot_y": hotspot_y, + "bbox": [min_x, min_y, max_x, max_y]} + + +def _validate_part(index: int, part) -> str: + if not isinstance(part, dict): + raise ValueError(f"part {index} is not an object: {part!r}") + kind = part.get("type") + if kind not in PART_SCHEMA: + raise ValueError(f"part {index}: unknown type {kind!r}; expected one of " + f"{', '.join(sorted(PART_SCHEMA))}") + spec = PART_SCHEMA[kind] + for key in spec["required"]: + if key not in part: + raise ValueError(f"part {index} ({kind}) is missing {key!r}") + allowed = set(spec["required"]) | set(spec["optional"]) | {"type", "style", "antialias"} + for key in part: + if key not in allowed: + raise ValueError(f"part {index} ({kind}): unexpected {key!r}; " + f"allowed: {', '.join(sorted(allowed))}") + if kind == "polygon": + pts = part["points"] + if not isinstance(pts, list) or len(pts) < 2: + raise ValueError(f"part {index} (polygon) needs at least two points") + for pt in pts: + if not (isinstance(pt, (list, tuple)) and len(pt) == 2): + raise ValueError(f"part {index} (polygon): each point is [x, y], got {pt!r}") + return kind + + +def _part_element(part: dict) -> ET.Element: + kind = part["type"] + node = ET.Element(kind) + if kind == "polygon": + for n, (px, py) in enumerate(part["points"], start=1): + node.set(f"x{n}", _fmt(px)) + node.set(f"y{n}", _fmt(py)) + node.set("closed", "true" if part.get("closed", True) else "false") + elif kind == "text": + node.set("x", _fmt(part["x"])) + node.set("y", _fmt(part["y"])) + node.set("text", str(part["text"])) + node.set("rotation", _fmt(part.get("rotation", 0))) + node.set("font", f"Sans Serif,{int(part.get('size', 9))},-1,5,50,0,0,0,0,0") + node.set("color", str(part.get("color", "#000000"))) + return node + else: + for key in PART_SCHEMA[kind]["required"] + PART_SCHEMA[kind]["optional"]: + if key in part: + node.set(key, _fmt(part[key])) + node.set("antialias", "true" if part.get("antialias", True) else "false") + node.set("style", part.get("style", DEFAULT_STYLE)) + return node + + +def _fmt(v) -> str: + """Numbers the way QElectroTech writes them: no trailing .0.""" + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, (int, float)): + f = float(v) + return str(int(f)) if f == int(f) else repr(f) + return str(v) + + +def tool_element_build(output: str, names: dict, parts: list, + terminals: list | None = None, + link_type: str = "simple", + informations: dict | None = None, + uuid: str | None = None) -> dict: + """Write a .elmt element definition. + + Unlike a project, an element definition is not rewritten by + QElectroTech on a round trip, so generating one here is safe in a way + that generating a .qet would not be: there is no toXml() that will + drop what this writer did not know to emit. + + What it will not do is invent geometry. The caller supplies the parts; + this validates them against the schema the shipped collection actually + uses, computes the width/height/hotspot header so the declared box + contains the drawing, and refuses anything it cannot place. + """ + terminals = terminals or [] + if not isinstance(names, dict) or not names: + raise ValueError('names must be a non-empty object, e.g. {"en": "Coil", "fr": "Bobine"}') + if link_type not in LINK_TYPES: + raise ValueError(f"unknown link_type {link_type!r}; expected one of " + f"{', '.join(LINK_TYPES)}") + if not isinstance(parts, list): + raise ValueError("parts must be a list") + for i, part in enumerate(parts): + _validate_part(i, part) + for i, t in enumerate(terminals): + if not isinstance(t, dict): + raise ValueError(f"terminal {i} is not an object: {t!r}") + for key in ("x", "y", "orientation"): + if key not in t: + raise ValueError(f"terminal {i} is missing {key!r}") + if t["orientation"] not in TERMINAL_ORIENTATIONS: + raise ValueError(f"terminal {i}: orientation is one of " + f"{', '.join(TERMINAL_ORIENTATIONS)}, got {t['orientation']!r}") + # A master with no terminal cannot be wired, and a slave with none + # cannot be placed on a rail -- both are silent failures at use time. + if link_type in ("master", "slave", "simple") and not terminals: + raise ValueError(f"a {link_type} element with no terminals cannot be connected; " + "add terminals, or use link_type 'thumbnail' for a drawing-only element") + + geometry = _element_geometry(parts, terminals) + + root = ET.Element("definition", { + "version": "0.100.0", "type": "element", "link_type": link_type, + "width": str(geometry["width"]), "height": str(geometry["height"]), + "hotspot_x": str(geometry["hotspot_x"]), "hotspot_y": str(geometry["hotspot_y"]), + }) + ET.SubElement(root, "uuid", {"uuid": uuid or "{" + str(__import__("uuid").uuid4()) + "}"}) + names_node = ET.SubElement(root, "names") + for lang in sorted(names): + ET.SubElement(names_node, "name", {"lang": lang}).text = str(names[lang]) + if informations: + kind = ET.SubElement(root, "kindInformations") + for key in sorted(informations): + ET.SubElement(kind, "kindInformation", {"name": key}).text = str(informations[key]) + ET.SubElement(root, "informations") + description = ET.SubElement(root, "description") + for part in parts: + description.append(_part_element(part)) + for t in terminals: + attrs = {"x": _fmt(t["x"]), "y": _fmt(t["y"]), + "orientation": t["orientation"], + "type": t.get("type", "Generic"), + "uuid": "{" + str(__import__("uuid").uuid4()) + "}"} + if t.get("name"): + attrs["name"] = str(t["name"]) + ET.SubElement(description, "terminal", attrs) + + out = Path(output).expanduser() + out.parent.mkdir(parents=True, exist_ok=True) + ET.indent(root, space=" ") + out.write_bytes(ET.tostring(root, encoding="utf-8", xml_declaration=True)) + + # Read it back with the same reader every other tool here uses, rather + # than reporting what was intended. + check = tool_element_info(str(out)) + return {"ok": True, "output": str(out), "bytes": out.stat().st_size, + **geometry, + # The order add_conductor will use, which is not the order the + # caller listed them in. + "terminal_index_order": [t["name"] or f"({t['x']},{t['y']})" + for t in check["terminals"]], + "verified": check} + + +# -------------------------------------------------------------------------- +# qet_edit: drive the scripting API, then prove what it did +# -------------------------------------------------------------------------- + +# op name -> (qet method, argument spec). A spec entry is (json key, kind), +# where kind says how the value is turned into JavaScript and, for "folio" +# and "elmt", that it may be a "$name" reference to an earlier op's result. +OPS = { + "add_folio": (None, []), + "set_folio_title": ("setFolioTitle", [("folio", "folio"), ("title", "str")]), + "add_element": ("addElement", [("folio", "folio"), ("path", "str"), + ("x", "num"), ("y", "num")]), + "set_position": ("setElementPosition", [("folio", "folio"), ("element", "elmt"), + ("x", "num"), ("y", "num")]), + "move_element": ("moveElement", [("folio", "folio"), ("element", "elmt"), + ("dx", "num"), ("dy", "num")]), + "rotate_element": ("rotateElement", [("folio", "folio"), ("element", "elmt"), + ("angle", "num")]), + "set_label": ("setElementLabel", [("folio", "folio"), ("element", "elmt"), + ("label", "str")]), + "set_info": ("setElementInfo", [("folio", "folio"), ("element", "elmt"), + ("key", "str"), ("value", "str")]), + "add_conductor": ("addConductor", [("folio", "folio"), + ("from", "elmt"), ("from_terminal", "num"), + ("to", "elmt"), ("to_terminal", "num")]), + "delete_element": ("deleteElement", [("folio", "folio"), ("element", "elmt")]), + "set_conductor": ("setConductorProperty", [("folio", "folio"), ("element", "elmt"), + ("terminal", "num"), ("property", "str"), + ("value", "str")]), + "move_conductor_segment": ("moveConductorSegment", [("folio", "folio"), ("element", "elmt"), + ("terminal", "num"), ("segment", "num"), + ("dx", "num"), ("dy", "num")]), + "link_elements": ("linkElements", [("folio", "folio"), ("element", "elmt"), + ("to_folio", "folio"), ("to", "elmt")]), + "link_plc_io": ("linkElements", [("folio", "folio"), ("element", "elmt"), + ("to_folio", "folio"), ("to", "elmt"), + ("io_index", "folio")]), + "unlink_element": ("unlinkElement", [("folio", "folio"), ("element", "elmt")]), + "add_plc_io": ("addPlcIO", [("folio", "folio"), ("element", "elmt"), + ("type", "str"), ("address", "str"), + ("function", "str"), ("comment", "str")]), + "set_plc_io": ("setPlcIO", [("folio", "folio"), ("element", "elmt"), + ("index", "folio"), ("property", "str"), + ("value", "str")]), + "remove_plc_io": ("removePlcIO", [("folio", "folio"), ("element", "elmt"), + ("index", "folio")]), + # Texts and shapes have no uuid; they are addressed by index into a + # position-sorted listing, and add_text/add_shape return that index so + # it can be named as "$id". Indexes shift when one is added or deleted. + "delete_conductor": ("deleteConductor", [("folio", "folio"), ("element", "elmt"), + ("terminal", "num")]), + "remove_folio": ("removeFolio", [("folio", "folio")]), + "set_folio": ("setFolioProperty", [("folio", "folio"), ("property", "str"), + ("value", "str")]), + "add_terminal_strip": ("addTerminalStrip", [("installation", "str"), ("location", "str"), + ("name", "str")]), + "remove_terminal_strip": ("removeTerminalStrip", [("strip", "folio")]), + "add_to_strip": ("addTerminalToStrip", [("strip", "folio"), ("folio", "folio"), + ("element", "elmt")]), + "group_terminals": ("groupTerminals", [("strip", "folio"), ("indices", "indices")]), + "bridge_terminals": ("bridgeTerminals", [("strip", "folio"), ("indices", "indices")]), + "sort_terminal_strip": ("sortTerminalStrip", [("strip", "folio")]), + "add_autonum": ("addAutoNum", [("kind", "str"), ("name", "str"), + ("parts", "list")]), + "remove_autonum": ("removeAutoNum", [("kind", "str"), ("name", "str")]), + "use_conductor_autonum": ("useConductorAutoNum", [("folio", "folio"), ("name", "str")]), + "use_element_autonum": ("useElementAutoNum", [("name", "str")]), + "number_element": ("numberElement", [("folio", "folio"), ("element", "elmt")]), + # The text fields drawn on a symbol. Indexed within the element's own + # list, which follows its definition and shifts on delete (and undo of a + # delete puts the field back at the end). + "add_element_text": ("addElementText", [("folio", "folio"), ("element", "elmt"), + ("source", "str"), ("value", "str"), + ("x", "num"), ("y", "num")]), + "set_element_text": ("setElementTextProperty", [("folio", "folio"), ("element", "elmt"), + ("index", "folio"), ("property", "str"), + ("value", "str")]), + "delete_element_text": ("deleteElementText", [("folio", "folio"), ("element", "elmt"), + ("index", "folio")]), + # 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 + # wire numbers, as they do on a paste in the application. + "insert_folio": ("insertFolio", [("position", "folio")]), + # Reads, not edits: the result is reported in the operations list, so a + # follow-up call can lay something out relative to it. + "element_geometry": ("elementGeometry", [("folio", "folio"), ("element", "elmt")]), + # Undo/redo act on QElectroTech's undo stack for this run, one command at + # a time. Consecutive edits to the same property or information key merge + # into one command, so one undo can revert several of them. + "undo": ("undo", []), + "redo": ("redo", []), + "search_and_replace": ("searchAndReplace", [("kind", "str"), ("field", "str"), + ("pattern", "str"), ("replacement", "str"), + ("regex", "bool"), ("case_sensitive", "bool")]), + "set_project_title": ("setProjectTitle", [("title", "str")]), + "set_folio_border": ("setFolioBorder", [("folio", "folio"), ("property", "str"), + ("value", "str")]), + "embed_title_block_template": ("embedTitleBlockTemplate", [("name", "str")]), + "duplicate_elements": ("duplicateElements", [("folio", "folio"), ("elements", "elmts"), + ("to_folio", "folio"), ("x", "num"), ("y", "num")]), + "add_text": ("addText", [("folio", "folio"), ("text", "str"), + ("x", "num"), ("y", "num")]), + "set_text": ("setTextContent", [("folio", "folio"), ("index", "folio"), + ("text", "str")]), + "set_text_color": ("setTextColor", [("folio", "folio"), ("index", "folio"), + ("color", "str")]), + "rotate_text": ("setTextRotation", [("folio", "folio"), ("index", "folio"), + ("angle", "num")]), + "delete_text": ("deleteText", [("folio", "folio"), ("index", "folio")]), + "add_shape": ("addShape", [("folio", "folio"), ("shape", "str"), + ("x1", "num"), ("y1", "num"), + ("x2", "num"), ("y2", "num")]), + "set_shape": ("setShapeProperty", [("folio", "folio"), ("index", "folio"), + ("property", "str"), ("value", "str")]), + "add_image": ("addImage", [("folio", "folio"), ("file", "str"), + ("x", "num"), ("y", "num")]), + "add_pdf_page": ("addPdfPage", [("folio", "folio"), ("file", "str"), + ("page", "num"), ("dpi", "num"), + ("x", "num"), ("y", "num")]), + "scale_image": ("setImageScale", [("folio", "folio"), ("index", "folio"), + ("factor", "num")]), + "rotate_image": ("setImageRotation", [("folio", "folio"), ("index", "folio"), + ("angle", "num")]), + "delete_image": ("deleteImage", [("folio", "folio"), ("index", "folio")]), + "delete_shape": ("deleteShape", [("folio", "folio"), ("index", "folio")]), + "add_polygon": ("addPolygon", [("folio", "folio"), ("points", "points"), + ("closed", "bool")]), + "set_shape_polygon": ("setShapePolygon", [("folio", "folio"), ("index", "folio"), + ("points", "points")]), + "add_path": ("addPath", [("folio", "folio"), ("nodes", "nodes"), + ("closed", "bool")]), + "set_shape_path_nodes": ("setShapePathNodes", [("folio", "folio"), ("index", "folio"), + ("nodes", "nodes")]), + "set_shape_closed": ("setShapeClosed", [("folio", "folio"), ("index", "folio"), + ("closed", "bool")]), + "add_table": ("addTable", [("folio", "folio"), ("kind", "str"), + ("name", "str"), ("query", "str")]), + "set_table_position": ("setTablePosition", [("folio", "folio"), ("table", "folio"), + ("x", "num"), ("y", "num")]), + "delete_table": ("deleteTable", [("folio", "folio"), ("table", "folio")]), +} + +SHAPES = ["line", "rectangle", "ellipse", "polygon"] +FOLIO_BORDER_PROPERTIES = ["columns", "column-width", "display-columns", + "rows", "row-height", "display-rows"] +ELEMENT_TEXT_SOURCES = ["text", "info", "composite"] +ELEMENT_TEXT_PROPERTIES = ["text", "source", "info", "composite", "frame", "size", + "x", "y", "rotation", "width"] +SHAPE_PROPERTIES = ["color", "fill", "width", "line-style", "rotation"] +AUTONUM_KINDS = ["conductor", "element", "folio"] +SEARCH_REPLACE_KINDS = ["element_info", "conductor", "text"] +FOLIO_PROPERTIES = ["title", "author", "filename", "plant", "locmach", + "indexrev", "folio", "template"] + +# Accepted by set_conductor. The names are the project file's own, so what +# a script sets is what qet_conductors reports back. +CONDUCTOR_PROPERTIES = ["num", "formula", "function", "bus", "cable", + "tension_protocol", "conductor_color", + "conductor_section", "color", "text_color", + # the conductor's look, under the file's own names + "color2", "bicolor", "style", "dash-size", + "condsize", "numsize", "displaytext"] + +# Methods this tool needs that only exist in a build carrying the drawing +# verbs. Probed in the script rather than assumed, because the failure mode +# otherwise is a TypeError on line N of a generated file the caller never +# sees, reported as "the edit failed". +_REQUIRED_METHODS = sorted({m for m, _ in OPS.values() if m} | + {"save", "folioCount", "conductorCount", "elementCount"}) + +_MARKER = "QETEDIT " + + +def _js(value) -> str: + """A JSON literal is a JavaScript literal for every type used here.""" + return json.dumps(value) + + +def _build_script(operations: list, output: str) -> str: + """Turn the operation list into a script, or raise on a bad operation. + + Every op is validated here, before QElectroTech is launched at all: a + typo in an op name should cost nothing, not a process start and a + JavaScript exception. + """ + refs: set[str] = set() + lines = [ + "// generated by qet-mcp; do not edit", + "var R = {};", # $name -> value from an earlier op + "var missing = [];", + f"var need = {_js(_REQUIRED_METHODS)};", + "for (var i = 0; i < need.length; i++) {", + " if (typeof qet[need[i]] !== 'function') missing.push(need[i]);", + "}", + f"qet.log({_js(_MARKER)} + JSON.stringify(" + "{kind: 'capabilities', missing: missing}));", + "var stop = false;", + "if (missing.length === 0) {", + ] + + def ref_or(value, kind: str, op_index: int, key: str) -> str: + if kind == "elmts": + if not isinstance(value, list) or not value or not all(isinstance(v, str) for v in value): + raise ValueError(f"operation {op_index}: {key!r} must be a non-empty list of " + f"elements (uuids or \"$id\" references), got {value!r}") + return "[" + ", ".join(ref_or(v, "elmt", op_index, key) for v in value) + "]" + if isinstance(value, str) and value.startswith("$"): + indexed = re.fullmatch(r"\$([A-Za-z0-9_]+)\[(\d+)\]", value) + if indexed: + # one item of a list result, e.g. the second copy from + # duplicate_elements + name, n = indexed.group(1), int(indexed.group(2)) + if name not in refs: + raise ValueError( + f"operation {op_index} refers to {value!r}, which no earlier " + f"operation defined (set \"id\": {name!r} on the op that creates it)") + return f"R[{_js(name)}][{n}]" + name = value[1:] + if name not in refs: + raise ValueError( + f"operation {op_index} refers to {value!r}, which no earlier " + f"operation defined (set \"id\": {name!r} on the op that creates it)") + return f"R[{_js(name)}]" + if kind == "num": + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise ValueError(f"operation {op_index}: {key!r} must be a number, " + f"got {value!r}") + return _js(value) + if kind == "list": + if not isinstance(value, list) or not all(isinstance(x, str) for x in value): + raise ValueError(f"operation {op_index}: {key!r} must be a list of strings, " + f"got {value!r}") + return _js(value) + if kind == "indices": + if (not isinstance(value, list) or not value + or not all(isinstance(x, int) and not isinstance(x, bool) for x in value)): + 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 == "folio": + if not isinstance(value, int) or isinstance(value, bool): + raise ValueError(f"operation {op_index}: {key!r} must be a folio index " + f"or a \"$name\" reference, got {value!r}") + return _js(value) + if kind == "bool": + if not isinstance(value, bool): + raise ValueError(f"operation {op_index}: {key!r} must be true or false, " + f"got {value!r}") + return _js(value) + if kind in ("points", "nodes"): + def _num(v): + return isinstance(v, (int, float)) and not isinstance(v, bool) + if not isinstance(value, list) or len(value) < 2: + raise ValueError(f"operation {op_index}: {key!r} must be a list of at " + f"least 2 {'points' if kind == 'points' else 'nodes'}, " + f"got {value!r}") + for item in value: + if not isinstance(item, dict) or not _num(item.get("x")) or not _num(item.get("y")): + raise ValueError(f"operation {op_index}: {key!r} entries must be " + f"{{\"x\": num, \"y\": num, ...}}, got {item!r}") + if kind == "nodes": + if "kind" in item and item["kind"] not in ("corner", "smooth", "symmetric"): + raise ValueError(f"operation {op_index}: {key!r} entry kind " + f"{item['kind']!r} must be corner, smooth or symmetric") + for hkey in ("inHandle", "outHandle"): + if hkey in item and (not isinstance(item[hkey], dict) + or not _num(item[hkey].get("x")) + or not _num(item[hkey].get("y"))): + raise ValueError(f"operation {op_index}: {key!r} entry " + f"{hkey!r} must be {{\"x\": num, \"y\": num}}") + return _js(value) + return _js("" if value is None else str(value)) + + for i, op in enumerate(operations): + if not isinstance(op, dict): + raise ValueError(f"operation {i} is not an object: {op!r}") + name = op.get("op") + if name not in OPS: + raise ValueError(f"operation {i}: unknown op {name!r}; " + f"expected one of {', '.join(sorted(OPS))}") + method, spec = OPS[name] + if name == "set_conductor" and op.get("property") not in CONDUCTOR_PROPERTIES: + raise ValueError(f"operation {i}: unknown conductor property " + f"{op.get('property')!r}; expected one of " + f"{', '.join(CONDUCTOR_PROPERTIES)}") + if name == "set_folio" and op.get("property") not in FOLIO_PROPERTIES: + raise ValueError(f"operation {i}: unknown folio property " + f"{op.get('property')!r}; expected one of " + f"{', '.join(FOLIO_PROPERTIES)}") + if name in ("add_autonum", "remove_autonum") and op.get("kind") not in AUTONUM_KINDS: + raise ValueError(f"operation {i}: unknown kind {op.get('kind')!r}; " + f"expected one of {', '.join(AUTONUM_KINDS)}") + if name == "search_and_replace": + if op.get("kind") not in SEARCH_REPLACE_KINDS: + raise ValueError(f"operation {i}: unknown kind {op.get('kind')!r}; " + f"expected one of {', '.join(SEARCH_REPLACE_KINDS)}") + if op.get("kind") == "conductor" and op.get("field") not in CONDUCTOR_PROPERTIES: + raise ValueError(f"operation {i}: unknown conductor field " + f"{op.get('field')!r}; expected one of " + f"{', '.join(CONDUCTOR_PROPERTIES)}") + if op.get("kind") == "element_info" and not op.get("field"): + raise ValueError(f"operation {i}: element_info needs a non-empty " + f"\"field\" (information key)") + if name == "set_folio_border" and op.get("property") not in FOLIO_BORDER_PROPERTIES: + raise ValueError(f"operation {i}: unknown folio border property " + f"{op.get('property')!r}; expected one of " + f"{', '.join(FOLIO_BORDER_PROPERTIES)}") + if name == "add_element_text" and op.get("source") not in ELEMENT_TEXT_SOURCES: + raise ValueError(f"operation {i}: unknown source {op.get('source')!r}; " + f"expected one of {', '.join(ELEMENT_TEXT_SOURCES)}") + if name == "set_element_text" and op.get("property") not in ELEMENT_TEXT_PROPERTIES: + raise ValueError(f"operation {i}: unknown element-text property " + f"{op.get('property')!r}; expected one of " + f"{', '.join(ELEMENT_TEXT_PROPERTIES)}") + if name == "set_shape" and op.get("property") not in SHAPE_PROPERTIES: + raise ValueError(f"operation {i}: unknown shape property " + f"{op.get('property')!r}; expected one of " + f"{', '.join(SHAPE_PROPERTIES)}") + if name == "add_shape" and op.get("shape") not in SHAPES: + raise ValueError(f"operation {i}: unknown shape {op.get('shape')!r}; " + f"expected one of {', '.join(SHAPES)}") + args = [] + for key, kind in spec: + if key not in op: + raise ValueError(f"operation {i} ({name}) is missing {key!r}") + args.append(ref_or(op[key], kind, i, key)) + + ident = op.get("id") + if ident is not None: + if not isinstance(ident, str) or not ident or ident.startswith("$"): + raise ValueError(f"operation {i}: \"id\" must be a non-empty name " + f"without a leading $, got {ident!r}") + if ident in refs: + raise ValueError(f"operation {i}: \"id\" {ident!r} is already used") + + call = "qet.addFolio()" if method is None else f"qet.{method}({', '.join(args)})" + lines.append(" if (!stop) {") + lines.append(f" var v{i} = {call};") + if ident is not None: + lines.append(f" R[{_js(ident)}] = v{i};") + refs.add(ident) + lines.append( + f" qet.log({_js(_MARKER)} + JSON.stringify(" + f"{{kind: 'op', index: {i}, op: {_js(name)}, " + f"id: {_js(ident)}, result: v{i}}}));") + # An op that failed usually invalidates the ones after it -- a + # conductor to an element that was never placed is not a second, + # independent finding, it is noise on top of the first one. The + # three falsey returns are the three the API uses: false for a + # refused edit, "" for an addElement that placed nothing, -1 for an + # addFolio that added none. + # An empty list is a failure too (duplicateElements returns [] when it + # refuses). Duck-typed on .length, not Array.isArray: QJSEngine hands + # an empty QStringList back as an array-like wrapper for which + # Array.isArray is false, so that test never fired and the run went + # on past the failed operation. + lines.append(f" if (v{i} === false || v{i} === '' || v{i} === -1 || " + f"(typeof v{i} === 'object' && v{i} !== null && " + f"(v{i}.length === 0 || (v{i}.length === undefined && " + f"Object.keys(v{i}).length === 0)))) " + "stop = true;") + lines.append(" }") + lines.append(" // ---- end of operations ----") + + # Save even after a failed op: a partial result that can be inspected + # beats no result at all, and the diff is what says how far it got. + lines.append(f" var saved = qet.save({_js(output)});") + lines.append(f" qet.log({_js(_MARKER)} + JSON.stringify(" + "{kind: 'save', result: saved, stopped_early: stop}));") + lines.append("}") + return "\n".join(lines) + "\n" + + +def _parse_script_output(text: str) -> dict: + """Read the marker lines the generated script emits. + + They arrive on stderr, not stdout: QetScriptApi::log() is a + QTextStream(stderr). Read both anyway rather than depending on that -- + the cost is nothing and the failure it prevents is silent (an edit that + worked, reported as having run no operations at all, which is what the + first version of this tool did).""" + caps, ops, saved, stopped = None, [], None, False + for line in text.splitlines(): + idx = line.find(_MARKER) + if idx < 0: + continue + try: + rec = json.loads(line[idx + len(_MARKER):]) + except json.JSONDecodeError: + continue + if rec.get("kind") == "capabilities": + caps = rec.get("missing") or [] + elif rec.get("kind") == "op": + rec.pop("kind", None) + # Not "in (False, ...)": 0 == False in Python, and 0 is a valid + # index/folio result. The script side already uses strict ===. + r = rec.get("result") + rec["succeeded"] = not (r is None or r is False or r == "" or r == [] or r == {} or + (isinstance(r, int) and not isinstance(r, bool) and r == -1)) + ops.append(rec) + elif rec.get("kind") == "save": + saved = bool(rec.get("result")) + stopped = bool(rec.get("stopped_early")) + return {"missing_methods": caps, "operations": ops, "saved": saved, + "stopped_early": stopped} + + +def tool_query(binary: str, project: str, sql: str, + elements_dir: str | None = None, timeout: int = 180) -> dict: + """Run a read-only SELECT against the project's SQLite database. + + This is the surface the rest of this server has done without. Every + other structural tool here re-derives its answer from the XML, because + the database was unreachable from outside the application; it is + reachable now, through the same guarded path QElectroTech's own + "Requête SQL personnalisée" box uses, so a structural question can be + asked of the database that already knows it. + + The three *_view names are the surface to depend on -- + element_nomenclature_view, project_summary_view, wiring_list_view. + They exist to be queried. The underlying tables are how the cache is + arranged today and a column may move; qet_query with sql omitted + lists both. + """ + proj = Path(project).expanduser() + if not proj.is_file(): + raise ValueError(f"no such project: {proj}") + + if sql: + # Same first-word rule projectDataBase::isReadOnlySelect() applies, + # checked here too so an obvious write is refused without paying + # for a process launch. QET still enforces it; this is not the + # guard, only an early one. + head = sql.strip().lstrip("(").split(None, 1)[0].upper() if sql.strip() else "" + if head not in ("SELECT", "WITH"): + raise ValueError("only read-only queries are allowed: a statement must " + f"begin with SELECT or WITH, not {head or '(nothing)'}") + + script = ("var out = %s ? qet.query(%s) : qet.tables();\n" + "qet.log(%s + JSON.stringify({kind: 'query', rows: out, " + "error: qet.queryError ? qet.queryError() : ''}));\n" + % (json.dumps(bool(sql)), json.dumps(sql or ""), json.dumps(_MARKER))) + + result = _run_qet(binary, [str(proj)], timeout=timeout, + elements_dir=elements_dir, script=script, tail=400_000) + streams = result.get("stdout", "") + "\n" + result.get("stderr", "") + rows, error = None, "" + for line in streams.splitlines(): + idx = line.find(_MARKER) + if idx < 0: + continue + try: + rec = json.loads(line[idx + len(_MARKER):]) + except json.JSONDecodeError: + continue + if rec.get("kind") == "query": + rows, error = rec.get("rows"), rec.get("error") or "" + for key in ("stdout", "stderr"): + kept = [ln for ln in result.get(key, "").splitlines() if _MARKER not in ln] + result[key] = "\n".join(kept)[-4000:] + + if rows is None: + result["ok"] = False + result.setdefault("hint", "the query returned nothing at all -- this build's " + "scripting API may predate qet.query()") + return result + if error: + result["ok"] = False + result["error"] = error + result["rows"] = rows + result["row_count"] = len(rows) + result["listing"] = not sql + return result + + +# -------------------------------------------------------------------------- +# qet_continuity: electrical continuity / ERC-style structural checks +# -------------------------------------------------------------------------- + +def tool_continuity(binary: str, project: str, folio: int | None = None, + elements_dir: str | None = None, timeout: int = 180) -> dict: + """Run qet.checkContinuity() and get its findings back. + + Two checks, against the live Terminal/Conductor object graph rather + than a heuristic read of the XML (that is qet_check's job, and the two + are complementary, not redundant -- qet_check looks at labels and + numbering conventions, this looks at the electrical graph itself): + unconnected_terminal (info -- routine, not necessarily a mistake) and + potential_mismatch (error -- two conductors QElectroTech's own + setConductorProperty() would always keep identical, found disagreeing, + which only happens from hand-edited XML, a legacy file, or an external + tool). See qet.checkContinuity()'s own doc comment (qetscriptapi.cpp) + for what this deliberately does not check: pin electrical direction/ + power conflicts and No/Nc/Common contact shorts, since QElectroTech's + terminal data model does not carry the information either would need. + """ + proj = Path(project).expanduser() + if not proj.is_file(): + raise ValueError(f"no such project: {proj}") + + folio_arg = -1 if folio is None else folio + script = ("var out = qet.checkContinuity(%s);\n" + "qet.log(%s + JSON.stringify({kind: 'continuity', findings: out}));\n" + % (json.dumps(folio_arg), json.dumps(_MARKER))) + + result = _run_qet(binary, [str(proj)], timeout=timeout, + elements_dir=elements_dir, script=script, tail=400_000) + streams = result.get("stdout", "") + "\n" + result.get("stderr", "") + findings = None + for line in streams.splitlines(): + idx = line.find(_MARKER) + if idx < 0: + continue + try: + rec = json.loads(line[idx + len(_MARKER):]) + except json.JSONDecodeError: + continue + if rec.get("kind") == "continuity": + findings = rec.get("findings") + for key in ("stdout", "stderr"): + kept = [ln for ln in result.get(key, "").splitlines() if _MARKER not in ln] + result[key] = "\n".join(kept)[-4000:] + + if findings is None: + result["ok"] = False + result.setdefault("hint", "no findings came back at all -- this build's " + "scripting API may predate qet.checkContinuity()") + return result + result["findings"] = findings + result["finding_count"] = len(findings) + result["errors"] = sum(1 for f in findings if f.get("severity") == "error") + result["warnings"] = sum(1 for f in findings if f.get("severity") == "warning") + result["info"] = sum(1 for f in findings if f.get("severity") == "info") + return result + + +# -------------------------------------------------------------------------- +# qet_element_search: find a symbol in the collection +# -------------------------------------------------------------------------- + +_ELEMENT_INDEX: dict = {} + + +def _fold(text: str) -> str: + """Case- and accent-insensitive form, so 'resistance' finds 'Résistance'.""" + import unicodedata + return "".join(c for c in unicodedata.normalize("NFKD", text.lower()) + if not unicodedata.combining(c)) + + +def _collection_signature(root: Path): + """Cheap change detector: file count and newest mtime, no parsing.""" + count, newest = 0, 0.0 + for f in root.rglob("*.elmt"): + count += 1 + try: + newest = max(newest, f.stat().st_mtime) + except OSError: + pass + return count, newest + + +def _index_collection(root: Path) -> list: + """Parse every .elmt under root once and keep what a search needs. + + Cached for the life of the process and rebuilt when the file count or + the newest modification time changes -- which is what makes a symbol + written by qet_element_build findable straight away, without the caller + knowing there is an index at all. + """ + key = str(root.resolve()) + sig = _collection_signature(root) + cached = _ELEMENT_INDEX.get(key) + if cached and cached["sig"] == sig: + return cached["items"] + + items = [] + for f in sorted(root.rglob("*.elmt")): + try: + d = ET.parse(f).getroot() + except (ET.ParseError, OSError): + continue + if d.tag != "definition": + continue + names = {n.get("lang", ""): (n.text or "").strip() for n in d.iter("name")} + kind = "" + for ki in d.iter("kindInformation"): + if ki.get("name") == "type": + kind = (ki.text or "").strip() + ordered, ambiguous = _terminals_in_index_order(list(d.iter("terminal"))) + terminals = [t.get("name") or "" for t in ordered] + rel = f.relative_to(root).as_posix() + items.append({ + "path": "common://" + rel, + "file": str(f), + "name": names.get("en") or names.get("fr") or next(iter(names.values()), ""), + "names": names, + "link_type": d.get("link_type", "simple"), + "kind": kind, + "terminals": len(terminals), + "terminal_names": terminals, # index order, not file order + "terminal_order_ambiguous": ambiguous, + "width": d.get("width"), "height": d.get("height"), + "haystack": _fold(" ".join([*names.values(), rel, kind])), + }) + _ELEMENT_INDEX[key] = {"sig": sig, "items": items} + return items + + +def tool_element_search(directory: str, query: str = "", link_type: str | None = None, + min_terminals: int | None = None, max_terminals: int | None = None, + kind: str | None = None, limit: int = 25) -> dict: + """Search an element collection by name, type and terminal count. + + Matches every word of query against all the translated names, the + element's path and its kind, ignoring case and accents -- so a French + or German search finds the same symbol an English one does. Results + carry a common:// path that qet_edit's add_element takes directly, and + the terminal names in the order add_conductor indexes them -- which is + top to bottom then left to right, not the order the file lists them. + """ + root = Path(directory).expanduser() + if not root.is_dir(): + raise ValueError(f"no such directory: {root}") + if link_type is not None and link_type not in LINK_TYPES: + raise ValueError(f"unknown link_type {link_type!r}; expected one of " + f"{', '.join(LINK_TYPES)}") + if limit < 1: + raise ValueError("limit must be >= 1") + + words = _fold(query).split() + matches = [] + for it in _index_collection(root): + if link_type and it["link_type"] != link_type: + continue + if kind and _fold(kind) not in _fold(it["kind"]): + continue + if min_terminals is not None and it["terminals"] < min_terminals: + continue + if max_terminals is not None and it["terminals"] > max_terminals: + continue + if not all(w in it["haystack"] for w in words): + continue + matches.append(it) + + # Whole-name hits before substring hits, then shorter names first: a + # search for "coil" should offer "Coil" before "Remanence coil, latching". + def rank(it): + name = _fold(it["name"]) + exact = 0 if (words and name == " ".join(words)) else 1 + starts = 0 if (words and name.startswith(words[0])) else 1 + return (exact, starts, len(it["name"]), it["path"]) + matches.sort(key=rank) + + shown = [{k: v for k, v in it.items() if k not in ("haystack", "names", "file")} + | {"languages": sorted(it["names"])} for it in matches[:limit]] + return {"query": query, "total_matches": len(matches), "returned": len(shown), + "indexed": len(_ELEMENT_INDEX[str(root.resolve())]["items"]), + "results": shown} + + +# Design-rule checks, each one a read-only query over the project database. +# +# Every check is a SELECT that returns the offending rows, so "no rows" is a +# pass and the same query is what a human would write by hand. The severity +# and the note say how much to trust a hit, because these are heuristics +# tuned against the 24 shipped examples, not standards: +# +# - Every text comparison is COALESCE'd. A value that was never set is NULL +# in the database when the element was placed in this session and an empty +# string when it was loaded from a file, and `col = ''` matches only the +# second -- which made the first version of these checks silently pass on +# exactly the freshly-edited projects qet_edit produces. The JavaScript +# side renders both as "", so the difference is invisible until a check +# fails to fire. Likewise exclude_from_bom is text, not a number. +# - An unnumbered conductor is '' in some files and '_' in others: '_' is the +# placeholder QElectroTech assigns when no numbering is configured, so +# testing for '' alone passed on industrial.qet's 36 placeholder conductors +# and on every project qet_edit builds without a numbering context. +# - Slaves and terminals are excluded from the duplicate-label check on +# purpose. A slave contact carries its master coil's label by design, and +# terminals repeat their numbers from one strip to the next; counting +# either would bury the real findings. +# - "simple" elements are checked for duplicates too but only as a warning: +# industrial.qet reuses V1..V6 across folios on purpose. +CHECKS = { + "duplicate_master_labels": { + "severity": "error", + "note": "Two master elements with the same label are ambiguous in every " + "report that keys on it (BOM, cross-references, wiring list).", + "sql": "SELECT label, COUNT(*) AS n FROM element_nomenclature_view " + "WHERE COALESCE(label,'') <> '' AND element_type = 'master' " + "GROUP BY label HAVING n > 1 ORDER BY n DESC, label", + }, + "duplicate_simple_labels": { + "severity": "warning", + "note": "Legitimate when a label is reused on purpose across folios " + "(industrial.qet does); worth a look otherwise.", + "sql": "SELECT label, COUNT(*) AS n FROM element_nomenclature_view " + "WHERE COALESCE(label,'') <> '' AND element_type = 'simple' " + "GROUP BY label HAVING n > 1 ORDER BY n DESC, label", + }, + "unlabelled_masters": { + "severity": "warning", + "note": "A master with no label cannot be told apart from its slaves' " + "cross-references.", + "sql": "SELECT folio, diagram_position, element_sub_type FROM " + "element_nomenclature_view WHERE element_type = 'master' AND COALESCE(label,'') = '' " + "ORDER BY folio, diagram_position", + }, + "unnumbered_conductors": { + "severity": "info", + "note": "Conductors with no wire number -- empty, or QElectroTech's own " + "'_' placeholder, which is what a conductor gets when no " + "numbering is configured. If every conductor is unnumbered the " + "project simply does not use wire numbering; a few among many " + "numbered ones is the finding.", + "sql": "SELECT COUNT(*) AS unnumbered, (SELECT COUNT(*) FROM wiring_list_view) AS total " + "FROM wiring_list_view WHERE COALESCE(wire_number,'') IN ('', '_') " + "HAVING unnumbered > 0", + }, + "empty_folios": { + "severity": "info", + "note": "Folios with no element on them. Often cover pages, sometimes " + "left behind by a deleted drawing.", + "sql": "SELECT p.pos AS position, p.title AS title FROM diagram d " + "JOIN project_summary_view p ON p.pos = d.pos " + "WHERE NOT EXISTS (SELECT 1 FROM element e WHERE e.diagram_uuid = d.uuid) " + "ORDER BY p.pos", + }, + "masters_without_manufacturer_reference": { + "severity": "info", + "note": "Masters that will show a blank article number in the BOM.", + "sql": "SELECT label, folio, diagram_position FROM element_nomenclature_view " + "WHERE element_type = 'master' AND COALESCE(manufacturer_reference,'') = '' " + "AND COALESCE(exclude_from_bom,'') IN ('', '0', 'false') " + "ORDER BY folio, diagram_position", + }, +} + + +def tool_check(binary: str, project: str, checks: list | None = None, + sample: int = 10, elements_dir: str | None = None, + timeout: int = 180) -> dict: + """Run design-rule checks over a project in one QElectroTech launch. + + Every check is a read-only SELECT over the project database, so this is + qet_query with the questions already written down. It exists because the + useful questions are always the same handful and re-deriving them per + conversation is where the mistakes creep in -- the first draft of the + duplicate-label check counted slave contacts, which share their coil's + label by design and flagged nearly every relay. + """ + proj = Path(project).expanduser() + if not proj.is_file(): + raise ValueError(f"no such project: {proj}") + chosen = list(CHECKS) if not checks else list(checks) + for name in chosen: + if name not in CHECKS: + raise ValueError(f"unknown check {name!r}; expected one of " + f"{', '.join(sorted(CHECKS))}") + if sample < 0: + raise ValueError("sample must be >= 0") + + queries = {name: CHECKS[name]["sql"] for name in chosen} + script = ("var Q = %s;\nfor (var k in Q) {\n" + " var rows = qet.query(Q[k]);\n" + " qet.log(%s + JSON.stringify({kind: 'check', name: k, rows: rows, " + "error: qet.queryError()}));\n}\n" % (json.dumps(queries), json.dumps(_MARKER))) + result = _run_qet(binary, [str(proj)], timeout=timeout, + elements_dir=elements_dir, script=script, tail=2_000_000) + streams = result.get("stdout", "") + "\n" + result.get("stderr", "") + + got = {} + for line in streams.splitlines(): + idx = line.find(_MARKER) + if idx < 0: + continue + try: + rec = json.loads(line[idx + len(_MARKER):]) + except json.JSONDecodeError: + continue + if rec.get("kind") == "check": + got[rec["name"]] = rec + + findings, errors, passed = [], [], [] + for name in chosen: + rec = got.get(name) + if rec is None: + errors.append({"check": name, "error": "no result came back"}) + continue + if rec.get("error"): + errors.append({"check": name, "error": rec["error"]}) + continue + rows = rec.get("rows") or [] + if not rows: + passed.append(name) + continue + findings.append({"check": name, "severity": CHECKS[name]["severity"], + "count": len(rows), "note": CHECKS[name]["note"], + "rows": rows[:sample]}) + + order = {"error": 0, "warning": 1, "info": 2} + findings.sort(key=lambda f: (order[f["severity"]], f["check"])) + return {"ok": not errors and not any(f["severity"] == "error" for f in findings), + "summary": {"errors": sum(f["severity"] == "error" for f in findings), + "warnings": sum(f["severity"] == "warning" for f in findings), + "info": sum(f["severity"] == "info" for f in findings), + "passed": len(passed), "check_failures": len(errors)}, + "findings": findings, "passed": passed, "check_failures": errors} + + +def tool_project_new(binary: str, output: str, title: str = "Untitled", + folios=1, author: str = "", overwrite: bool = False, + elements_dir: str | None = None, timeout: int = 180) -> dict: + """Create a new, empty project so qet_edit has something to start from. + + Every other edit tool needs an existing .qet, which made building a + schematic from nothing impossible. The obvious candidate, + examples/Projet_vierge.qet, is not blank: it is a 600 KB real project + with 23 elements and 15 conductors. + + So this writes the smallest project QElectroTech will open -- one + element with a title, no folios -- and then has QElectroTech itself + add the folios and save. What is left on disk is QElectroTech's own + canonical output, not the hand-written skeleton, which is why this is + not the "write .qet XML directly" route that was rejected: the + skeleton never reaches the result, and the result is checked by + reading it back. + + folios is a count, or a list of folio titles. + """ + out = Path(output).expanduser() + if out.exists() and not overwrite: + raise ValueError(f"{out} already exists; pass overwrite=true to replace it") + if isinstance(folios, bool) or not isinstance(folios, (int, list)): + raise ValueError("folios must be a count or a list of titles") + titles = ([""] * folios) if isinstance(folios, int) else [str(t) for t in folios] + if not 0 <= len(titles) <= 200: + raise ValueError("folios must be between 0 and 200") + if not isinstance(title, str) or not title.strip(): + raise ValueError("title must be a non-empty string") + + from xml.sax.saxutils import quoteattr + script = ["var t = %s;" % json.dumps(titles), "var made = [];", + "for (var i = 0; i < t.length; i++) {", + " var f = qet.addFolio(); made.push(f);", + " if (f >= 0 && t[i]) qet.setFolioTitle(f, t[i]);", + " if (f >= 0 && %s) qet.setFolioProperty(f, 'author', %s);" % + (json.dumps(bool(author)), json.dumps(author)), + "}", + "var saved = qet.save(%s);" % json.dumps(str(out)), + "qet.log(%s + JSON.stringify({kind: 'new', folios: made, saved: saved}));" + % json.dumps(_MARKER)] + + out.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="qet-mcp-new-") as tmp: + skeleton = Path(tmp) / "skeleton.qet" + skeleton.write_text('\n\n' + % quoteattr(title), encoding="utf-8") + result = _run_qet(binary, [str(skeleton)], timeout=timeout, + elements_dir=elements_dir, script="\n".join(script), tail=200_000) + + rec = None + for line in (result.get("stdout", "") + "\n" + result.get("stderr", "")).splitlines(): + idx = line.find(_MARKER) + if idx >= 0: + try: + r = json.loads(line[idx + len(_MARKER):]) + except json.JSONDecodeError: + continue + if r.get("kind") == "new": + rec = r + for key in ("stdout", "stderr"): + result[key] = "\n".join(l for l in result.get(key, "").splitlines() + if _MARKER not in l)[-2000:] + + if rec is None or not rec.get("saved") or not out.is_file(): + result["ok"] = False + result["hint"] = ("QElectroTech did not write the project; this build's scripting " + "API may predate addFolio()/save()") + return result + if any(f < 0 for f in rec["folios"]): + result["ok"] = False + result["hint"] = "a folio could not be added" + return result + + # Read back what is actually on disk rather than report what was asked for. + info = tool_project_info(str(out)) + if info["title"] != title or info["folio_count"] != len(titles): + result["ok"] = False + result["hint"] = (f"the file on disk has title {info['title']!r} and " + f"{info['folio_count']} folio(s), not what was requested") + result["output"] = str(out) + result["project"] = info + return result + + +def tool_edit(binary: str, project: str, operations: list, output: str, + elements_dir: str | None = None, timeout: int = 180) -> dict: + """Apply edits through the scripting API and report what actually changed. + + The point is the last part. The scripting API returns a bool per call, + which says the call was accepted, not that the file came out the way + anyone intended -- so this runs qet_diff between the input project and + the saved result and puts that in the answer. A caller that trusts + "addConductor -> true" and stops there is back to trusting the + screenshot. + + The project is never written in place: output is a separate file, and + the original is what the diff is taken against. + """ + proj = Path(project).expanduser() + if not proj.is_file(): + raise ValueError(f"no such project: {proj}") + if not isinstance(operations, list) or not operations: + raise ValueError("operations must be a non-empty list") + out = Path(output).expanduser() + if out.resolve() == proj.resolve(): + raise ValueError("output must differ from project; this tool does not " + "edit a project in place") + + script = _build_script(operations, str(out)) + # QET interrupts a script at 30 s (kScriptTimeoutMs in qetscripting.cpp), + # independently of this timeout. Leaving room above it means a script + # that hits the engine's limit comes back as a script error we can + # report, rather than as our own opaque process timeout. + result = _run_qet(binary, [str(proj)], timeout=timeout, + elements_dir=elements_dir, script=script, tail=200_000) + streams = result.get("stdout", "") + "\n" + result.get("stderr", "") + result.update(_parse_script_output(streams)) + # The marker lines have been parsed into "operations"; leaving them in + # the reported streams as well just doubles the size of the answer. + for key in ("stdout", "stderr"): + kept = [ln for ln in result.get(key, "").splitlines() if _MARKER not in ln] + result[key] = "\n".join(kept)[-4000:] + result["output"] = str(out) + result["output_exists"] = out.exists() + + if result.get("missing_methods") is None and not result.get("timed_out"): + # The script's first act is to report which methods exist. No report + # means the script never ran -- a binary with no --run support, one + # that exited early, or the wrong executable -- and exit code 0 from + # something that did nothing is not success. + result["ok"] = False + result["hint"] = ("the binary never ran the script (no capability report came " + "back), so nothing was changed. Is it a QElectroTech build with " + "--run support?") + result["script"] = script + return result + + missing = result.get("missing_methods") + if missing: + result["ok"] = False + result["hint"] = ( + "this build's scripting API lacks " + ", ".join(missing) + + " -- it predates the drawing verbs, so nothing was changed") + result["script"] = script + return result + + for record in result.get("operations", []): + if not record["succeeded"]: + result["ok"] = False + result.setdefault("hint", + f"operation {record['index']} ({record['op']}) returned " + f"{record['result']!r}; later operations were skipped. " + "qet.log lines in stderr/stdout say why.") + break + + if result.get("saved") is False: + result["ok"] = False + result.setdefault("hint", "the edits were made but save() failed") + + if out.is_file(): + result["output_bytes"] = out.stat().st_size + try: + result["diff"] = tool_diff(str(proj), str(out)) + except ET.ParseError as exc: # a truncated or unwritten save + result["ok"] = False + result["diff_error"] = str(exc) + if not result.get("ok"): + result["script"] = script + return result + + TOOLS = [ { "name": "qet_project_info", @@ -628,6 +2070,409 @@ TOOLS = [ "handler": lambda a: tool_export(a["binary"], a["project"], a["format"], a["output"], a.get("timeout", 180)), }, + { + "name": "qet_edit", + "description": "Edit a project through QElectroTech's own scripting API " + "and report what actually changed. Places, moves, rotates, " + "labels and deletes elements, wires two terminals together, " + "and adds folios -- each through the same undo command the " + "GUI uses, so the result is undoable and reaches the project " + "database. Writes a new file, never the input, and returns a " + "qet_diff of the two. Needs a build whose scripting API " + "carries the drawing verbs; says so plainly if it does not.", + "inputSchema": { + "type": "object", + "properties": { + "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "project": {"type": "string", "description": "the .qet to start from; not modified"}, + "output": {"type": "string", "description": "where to write the edited project"}, + "operations": { + "type": "array", + "minItems": 1, + "description": + "Operations applied in order. Each is an object with \"op\" " + "and that op's arguments. Ops: " + ", ".join(sorted(OPS)) + ". " + "Give an op an \"id\" to name what it produced, then refer to " + "it later as \"$id\" -- that is how an element placed by " + "add_element gets wired by add_conductor, and how a folio made " + "by add_folio is addressed. Terminals are numbered by their " + "index in the element definition; qet_element_info lists them. " + "set_conductor addresses a conductor as the one on a given " + "terminal and applies the change to its whole electrical " + "potential, so name a terminal carrying exactly one conductor; " + "its \"property\" is one of " + ", ".join(CONDUCTOR_PROPERTIES) + + ". move_conductor_segment reroutes the drawn path itself rather " + "than a property of the potential -- addressed the same way (a " + "terminal carrying exactly one conductor), plus a segment index " + "into that conductor's own path. A segment only moves " + "perpendicular to its own direction, the same as dragging its " + "handle in the GUI: dx moves a vertical segment, dy moves a " + "horizontal one, the other of the pair is silently ignored, and " + "the two segments touching a terminal are static (refused, no " + "handle exists on them either). There is no query op to list " + "segments or their indexes first -- a freshly auto-routed " + "conductor between two terminals is a static segment, one or two " + "movable ones, then a static segment, in that order from the " + "first terminal; call with a guessed index and read \"succeeded\" " + "to check it landed on a movable one. " + "link_elements takes a folio for each end, since a master " + "and its slave are usually on different ones. " + "delete_conductor removes only the conductor on the named " + "terminal (which must carry exactly one). remove_folio shifts " + "later folio indexes down. set_folio takes one of " + + ", ".join(FOLIO_PROPERTIES) + ". " + "Auto-numbering: add_autonum defines a named context of kind " + "conductor, element or folio from parts written " + "\"type[:value[:increase]]\" (e.g. [\"string:W\", \"unit:1:1\"]); " + "use_conductor_autonum then makes new conductors on a folio " + "take their number from it, so define and select it BEFORE the " + "add_conductor ops it should number. For elements, " + "use_element_autonum selects the context and number_element applies " + "it to one element AFTER it is placed (add_element does not number " + "what it places); slaves and reports are refused, since they take " + "their label from their master. " + "Terminal strips: add_terminal_strip returns an index (name " + "it \"$id\"); add_to_strip puts a terminal-type element on " + "it, and refuses any other kind. group_terminals/bridge_terminals " + "take \"indices\" (at least two) into that strip's real-terminal " + "listing -- group merges onto whichever named position already has " + "the most terminals, not necessarily the first index given; bridge " + "refuses terminals that are not all at the same level. A group() call " + "can fully reorder the listing, not just shift indices after it -- " + "always re-list before addressing one by index again. " + "sort_terminal_strip reorders it canonically. " + "Images: add_image takes a file path (over 10 MB is refused) " + "and returns an index; the pixels are embedded in the saved " + "project. scale_image/rotate_image can change an image's sort " + "index, so rely on the \"$id\" only until the next scale or " + "rotate. " + "add_pdf_page renders one page of a PDF file to an image and " + "places it, through the same code path as the \"add image\" " + "toolbar action's own PDF support: \"page\" is 1-based, \"dpi\" " + "is the render resolution (the GUI dialog defaults to 150), and " + "the result is an ordinary image afterwards -- scale_image, " + "rotate_image and delete_image all apply to it same as any other. " + "Only reachable in a build with the QtPdf module (Qt >= 6.4); " + "some Qt6 distributions omit it, and the op is refused with a " + "clear reason rather than being absent, so check the op's own " + "\"succeeded\"/result rather than assuming a missing method. " + "insert_folio puts a new folio at a position (0 = first, the " + "folio count = last) and returns its index; element_geometry reads " + "an element's x, y, rotation and the box it occupies " + "(left/top/right/bottom) and reports it in the result -- use it to " + "lay things out relative to each other across calls; undo/redo step " + "QElectroTech's undo stack (consecutive edits to one property merge, " + "so one undo can revert several) and fail if there is nothing to " + "undo. search_and_replace finds and replaces a substring or (with " + "\"regex\": true) a regular expression within one text field, " + "across every folio, as a single undo step -- unlike doing the " + "same with a read op and set_conductor/set_info/set_text in a " + "loop, which would leave one undo entry per item touched. \"kind\" " + "is element_info (\"field\" is an information key such as " + "\"label\"), conductor (\"field\" is one of " + + ", ".join(CONDUCTOR_PROPERTIES) + " -- replacing on one conductor " + "of a potential updates the whole potential, the same as " + "set_conductor always does) or text (independent texts; \"field\" " + "is ignored). This is NOT QElectroTech's own \"Search and replace\" " + "panel: that one is a batch overwrite-with-sentinel template built " + "for picking items from a tree interactively, a poor fit for a " + "script that can already say precisely which items it means. This " + "does what the name says instead -- an actual substring/regex " + "replace within each item's current value, touching only items " + "where it is found. Returns the number of items changed; never " + "matches an empty field. set_project_title renames the project. " + "set_folio_border sets one " + "of the folio frame's " + ", ".join(FOLIO_BORDER_PROPERTIES) + + " (counts 1-99, sizes 1-1000, display-* true/false). " + "embed_title_block_template copies a template into the project from " + "the common/company/custom collection that has it (only reachable if " + "the binary's compiled-in template path resolves to something real -- " + "typically a make install'd QET; there is no per-run override for this " + "one the way elements_dir is for elements, since QElectroTech reads " + "--common-tbt-dir before --run's own argument handling ever sees it, " + "so this tool cannot pass it through). set_folio's \"template\" property " + "then embeds-if-needed and applies it in one call; a template literally " + "named \"default\" reads back as \"\" afterwards, since QElectroTech " + "treats the two as the same thing. " + "duplicate_elements copies elements, with the conductors between " + "them, to a position (the top-left of the copied group's bounding " + "box; (0,0) keeps the source coordinates) on the same or another " + "folio: \"elements\" is a list of uuids or \"$id\" references, " + "and the result lists the copies in that same order, so " + "\"$copies[0]\" is the copy of the first. Copies come without " + "labels or wire numbers, as on a paste in the application. " + "Symbol text fields (the label, terminal names, values drawn on a " + "symbol): add_element_text (source text|info|composite; value is " + "the string, an information key such as \"label\", or a formula; " + "x/y are in the element's own coordinates) returns an index within " + "that element; set_element_text takes " + + ", ".join(ELEMENT_TEXT_PROPERTIES) + ". A field bound with source " + "\"info\" follows set_label/set_info. Indexes shift on delete. " + "Texts and shapes have no uuid: add_text/add_shape return an " + "index you can name as \"$id\", and the other text/shape ops " + "take it as \"index\". Indexes shift when one is added or " + "deleted. Shapes: " + ", ".join(SHAPES) + "; set_shape takes " + ", ".join(SHAPE_PROPERTIES) + + " (fill accepts a colour or \"none\"). " + "add_shape's own \"polygon\" is always the degenerate two-point " + "form (it shares add_shape's p1/p2 shape); add_polygon takes as " + "many points as wanted instead, as [{\"x\":.., \"y\":..}, ...] " + "in scene coordinates (at least 2), plus \"closed\"; " + "set_shape_polygon replaces an existing one's points the same " + "way. add_path places a curved shape -- a polygon's points plus, " + "per node, an optional \"kind\" (corner, the default; smooth; or " + "symmetric) and optional \"inHandle\"/\"outHandle\" bezier " + "control points, the same model the pen tool and node-edit mode " + "build; set_shape_path_nodes replaces an existing path's nodes. " + "set_shape_closed opens or closes a polygon or path (a no-op on " + "any other shape). set_shape_polygon/set_shape_path_nodes refuse " + "a shape of the wrong kind -- a shape made by add_shape is never " + "a valid target for either, and vice versa. A shape's index can " + "shift on any edit that moves it, not only an add or delete: " + "shapes are listed by current on-folio position, so changing one " + "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. " + "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 " + "query op) against element_nomenclature_view or " + "project_summary_view first to find one that returns real " + "columns; an empty query is refused, since each query widget " + "defaults to zero selected columns and produces a table with " + "no rows. Returns an index you can name as \"$id\". Every new " + "table lands at the same fixed (50, 50), so a folio getting " + "more than one must reposition all but the first with " + "set_table_position or they stack exactly on top of each " + "other. delete_table removes one; indexes shift afterwards. " + "PLC IO: a PLC master (elementData type Master, masterType " + "PLC) carries an IO table -- add_plc_io appends a row (type " + "is one of entree_digitale, sortie_digitale, " + "entree_analogique, sortie_analogique, entree_universelle, " + "sortie_universelle) and returns its index as \"$id\"; " + "set_plc_io changes one field (type, address, function or " + "comment) of an existing row; remove_plc_io deletes one and " + "shifts the indexes after it. None of the three are " + "undoable -- MasterPropertiesWidget's own PLC IO editor " + "isn't either, since it manages PLC linking through the " + "table rather than the ordinary link-tree undo path. " + "link_plc_io is link_elements plus an io_index: it links a " + "PLC slave onto one specific row of a PLC master's IO table " + "(io_index into that table, from add_plc_io's return or a " + "count of prior add_plc_io calls) rather than leaving which " + "row unspecified the way a plain link_elements call would. " + "If an op fails the rest are skipped, since they usually " + "depend on it.", + "items": {"type": "object"}, + }, + "elements_dir": { + "type": "string", + "description": "the common elements collection, e.g. a checkout's " + "elements/ directory. Required for \"common://\" " + "paths: the sandboxed run has no settings of its " + "own and would not find the collection otherwise. " + "An absolute .elmt path works without it.", + }, + "timeout": {"type": "integer", "default": 180}, + }, + "required": ["binary", "project", "output", "operations"], + }, + "handler": lambda a: tool_edit(a["binary"], a["project"], a["operations"], + a["output"], a.get("elements_dir"), + a.get("timeout", 180)), + }, + { + "name": "qet_query", + "description": "Run a read-only SQL SELECT against the project's SQLite " + "database and get rows back. Prefer the views " + "(element_nomenclature_view, project_summary_view, " + "wiring_list_view) over the raw tables. Omit sql to list what " + "is queryable. Only SELECT and WITH are permitted -- " + "QElectroTech enforces this itself, the same way it does for " + "the custom-query box in its own interface.", + "inputSchema": { + "type": "object", + "properties": { + "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "project": {"type": "string", "description": "the .qet to query; never modified"}, + "sql": {"type": "string", + "description": "a single SELECT or WITH...SELECT. " + "Omit to list the tables and views instead."}, + "elements_dir": {"type": "string"}, + "timeout": {"type": "integer", "default": 180}, + }, + "required": ["binary", "project"], + }, + "handler": lambda a: tool_query(a["binary"], a["project"], a.get("sql", ""), + a.get("elements_dir"), a.get("timeout", 180)), + }, + { + "name": "qet_continuity", + "description": "Electrical continuity / ERC-style checks against the live " + "Terminal/Conductor object graph, not a heuristic read of the " + "XML (that is qet_check; the two are complementary). " + "unconnected_terminal (info -- routine, not necessarily wrong) " + "and potential_mismatch (error -- two conductors on the same " + "electrical potential disagreeing on num/colour/section/" + "function/bus/cable, which QElectroTech's own edits never " + "produce, so it means hand-edited XML, a legacy file, or an " + "external tool). Does NOT check pin electrical direction/power " + "conflicts or No/Nc/Common contact shorts -- QElectroTech's " + "terminal data model carries neither. One QElectroTech " + "launch; read-only.", + "inputSchema": { + "type": "object", + "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"}, + "elements_dir": {"type": "string"}, + "timeout": {"type": "integer", "default": 180}, + }, + "required": ["binary", "project"], + }, + "handler": lambda a: tool_continuity(a["binary"], a["project"], a.get("folio"), + a.get("elements_dir"), a.get("timeout", 180)), + }, + { + "name": "qet_project_new", + "description": "Create a new, empty project to start a schematic from: a " + "title and any number of folios, written by QElectroTech " + "itself and read back to check. qet_edit needs an existing " + "project, and the shipped 'blank' example is not blank, so " + "this is the way to begin from nothing. Refuses to overwrite " + "unless told to.", + "inputSchema": { + "type": "object", + "properties": { + "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "output": {"type": "string", "description": "where to write the new .qet"}, + "title": {"type": "string", "description": "the project title"}, + "folios": {"description": "how many empty folios, or a list of folio titles", + "oneOf": [{"type": "integer", "minimum": 0, "maximum": 200}, + {"type": "array", "items": {"type": "string"}}], + "default": 1}, + "author": {"type": "string", "description": "set on every folio's title block"}, + "overwrite": {"type": "boolean", "default": False}, + "elements_dir": {"type": "string"}, + "timeout": {"type": "integer", "default": 180}, + }, + "required": ["binary", "output", "title"], + }, + "handler": lambda a: tool_project_new( + a["binary"], a["output"], a["title"], a.get("folios", 1), a.get("author", ""), + a.get("overwrite", False), a.get("elements_dir"), a.get("timeout", 180)), + }, + { + "name": "qet_element_search", + "description": "Find a symbol in an element collection by name (any " + "language, ignoring case and accents), link type, kind or " + "terminal count. Results carry a common:// path that " + "qet_edit's add_element takes directly, and the terminal " + "names in add_conductor's index order (top-to-bottom, then " + "left-to-right; not file order). Indexes the " + "collection on first use and re-indexes when it changes, so " + "a symbol written by qet_element_build is found at once.", + "inputSchema": { + "type": "object", + "properties": { + "directory": {"type": "string", + "description": "the collection root, e.g. a checkout's elements/ directory"}, + "query": {"type": "string", + "description": "words to find; every word must match some name, the path or the kind"}, + "link_type": {"type": "string", "enum": list(LINK_TYPES)}, + "kind": {"type": "string", "description": "the element's type information, e.g. coil, protection"}, + "min_terminals": {"type": "integer"}, + "max_terminals": {"type": "integer"}, + "limit": {"type": "integer", "default": 25}, + }, + "required": ["directory"], + }, + "handler": lambda a: tool_element_search( + a["directory"], a.get("query", ""), a.get("link_type"), + a.get("min_terminals"), a.get("max_terminals"), a.get("kind"), + a.get("limit", 25)), + }, + { + "name": "qet_check", + "description": "Run design-rule checks over a project and report findings by " + "severity: duplicate master labels (error), duplicate simple " + "labels and unlabelled masters (warning), unnumbered " + "conductors, empty folios and masters missing a manufacturer " + "reference (info). One QElectroTech launch; read-only. " + "These are heuristics tuned against QElectroTech's shipped " + "examples, not standards -- each finding carries a note " + "saying how far to trust it.", + "inputSchema": { + "type": "object", + "properties": { + "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "project": {"type": "string"}, + "checks": {"type": "array", "items": {"type": "string", "enum": sorted(CHECKS)}, + "description": "which checks to run; omit for all"}, + "sample": {"type": "integer", "default": 10, + "description": "how many offending rows to return per check"}, + "elements_dir": {"type": "string"}, + "timeout": {"type": "integer", "default": 180}, + }, + "required": ["binary", "project"], + }, + "handler": lambda a: tool_check(a["binary"], a["project"], a.get("checks"), + a.get("sample", 10), a.get("elements_dir"), + a.get("timeout", 180)), + }, + { + "name": "qet_element_build", + "description": "Write a .elmt element definition: named in one or more " + "languages, drawn from lines, rectangles, ellipses, circles, " + "arcs, polygons and text, with terminals to wire it by. " + "Computes the width/height/hotspot header so the declared box " + "contains the drawing, validates every part against the schema " + "the shipped collection uses, and reads the result back. " + "Writes the file directly; does not launch QElectroTech.", + "inputSchema": { + "type": "object", + "properties": { + "output": {"type": "string", "description": "path to write, ending .elmt"}, + "names": {"type": "object", + "description": 'translated names by language code, e.g. ' + '{"en": "Coil", "fr": "Bobine"}. French is ' + "QElectroTech's source language; give it if you can."}, + "parts": { + "type": "array", + "description": + 'the drawing. Each part is {"type": ...} plus its own keys: ' + 'line x1,y1,x2,y2; rect/ellipse/arc x,y,width,height ' + "(arc also start,angle); circle x,y,diameter; polygon " + 'points:[[x,y],...] and closed; text x,y,text with optional ' + "size, rotation, color. Any part may carry style and antialias. " + "Coordinates are the element's own, with (0,0) at its origin.", + "items": {"type": "object"}, + }, + "terminals": { + "type": "array", + "description": 'where conductors attach: {"x","y","orientation"} ' + "with orientation n, s, e or w, plus an optional " + 'name such as "A1". Their order here is the order ' + "qet_edit indexes terminals by position, not by this order: " + "top to bottom, then left to right. qet_element_build " + "returns the resulting index order.", + "items": {"type": "object"}, + }, + "link_type": {"type": "string", "enum": list(LINK_TYPES), + "description": "simple for an ordinary symbol, master/slave " + "for a cross-referenced pair, thumbnail for " + "a drawing with no terminals"}, + "informations": {"type": "object", + "description": "kindInformation entries, e.g. {\"type\": \"coil\"}"}, + "uuid": {"type": "string", "description": "reuse an existing uuid; " + "omit to generate one"}, + }, + "required": ["output", "names", "parts"], + }, + "handler": lambda a: tool_element_build( + a["output"], a["names"], a["parts"], a.get("terminals"), + a.get("link_type", "simple"), a.get("informations"), a.get("uuid")), + }, ] _BY_NAME = {t["name"]: t for t in TOOLS} diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py new file mode 100644 index 000000000..2c7c0fe3e --- /dev/null +++ b/misc/qet-mcp/test_qet_mcp.py @@ -0,0 +1,2191 @@ +#!/usr/bin/env python3 +""" +Regression suite for qet_mcp.py. + +Three layers, cheapest first: + + unit no QElectroTech needed: validation, script generation, the + terminal-order rule, the diff, the part schema, the protocol + protocol the real stdio transport, one JSON-RPC message per line + integration drives a built QElectroTech; skipped unless one is found + + python3 test_qet_mcp.py # unit + protocol + QET_BINARY=/path/to/qelectrotech \\ + QET_ELEMENTS=/path/to/qelectrotech/elements \\ + QET_EXAMPLES=/path/to/qelectrotech/examples \\ + python3 test_qet_mcp.py # everything + +Several tests exist because the behaviour they pin was once wrong and +looked right. Those say so in their docstring, so nobody "simplifies" one +away: a check that has never been seen to fail is not evidence. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +import xml.etree.ElementTree as ET +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) +import qet_mcp as m # noqa: E402 + +BINARY = os.environ.get("QET_BINARY", "") +ELEMENTS = os.environ.get("QET_ELEMENTS", "") +EXAMPLES = os.environ.get("QET_EXAMPLES", "") + +have_binary = bool(BINARY) and os.access(BINARY, os.X_OK) +have_elements = bool(ELEMENTS) and Path(ELEMENTS).is_dir() +have_examples = bool(EXAMPLES) and Path(EXAMPLES).is_dir() + +needs_binary = unittest.skipUnless(have_binary, "set QET_BINARY to a built qelectrotech") +needs_elements = unittest.skipUnless(have_binary and have_elements, + "set QET_BINARY and QET_ELEMENTS") +needs_examples = unittest.skipUnless(have_binary and have_examples, + "set QET_BINARY and QET_EXAMPLES") + +COIL = "common://10_electric/10_allpole/310_relays_contactors_contacts/01_coils/bobine_ka_a_remanence.elmt" +SLAVE = ("common://10_electric/10_allpole/310_relays_contactors_contacts/" + "02_contacts_cross_referencing/15_protection_contacts/contact_relais_nf_esclave.elmt") +TERMINAL = "common://10_electric/10_allpole/130_terminals_terminal_strips/borne_2.elmt" + +# No shipped element has masterType/slaveType "plc" -- these two minimal +# fixtures (derived from a real coil/slave pair) exist only so PLC IO tests +# have something to place. ElementsLocation only resolves an absolute .elmt +# path when it happens to sit under QETApp::commonElementsDirN(), so they +# are placed via a dedicated elements_dir pointed at this directory, not the +# real QET_ELEMENTS collection. +PLC_FIXTURES = str(HERE / "fixtures") +PLC_MASTER = "common://plc_master_test.elmt" +PLC_SLAVE = "common://plc_slave_test.elmt" + + +def png(path: Path) -> None: + """A real 64x32 PNG from the standard library, so no imaging dependency.""" + import struct + import zlib + w, h = 64, 32 + raw = b"".join(b"\x00" + bytes([255, 0, 0] * w) for _ in range(h)) + + def chunk(t, d): + c = struct.pack(">I", len(d)) + t + d + return c + struct.pack(">I", zlib.crc32(t + d) & 0xFFFFFFFF) + path.write_bytes(b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b"")) + + +def pdf(path: Path, page_colors=("1 0 0", "0 0 1")) -> None: + """A minimal, hand-built multi-page PDF -- one filled rectangle per + page, in a different colour each, so a test can tell which page a + render actually came from. No external tool (ghostscript, reportlab) + needed; the xref offsets are computed exactly as each object is + written, since QPdfDocument (pdfium) is not guaranteed to tolerate a + wrong one the way some readers repair-scan for.""" + n = len(page_colors) + page_objs = list(range(3, 3 + n)) # 3 .. 2+n + content_objs = list(range(3 + n, 3 + 2 * n)) # 3+n .. 2+2n + objects = {} + + objects[1] = b"<< /Type /Catalog /Pages 2 0 R >>" + kids = " ".join(f"{o} 0 R" for o in page_objs) + objects[2] = f"<< /Type /Pages /Kids [{kids}] /Count {n} >>".encode() + for i, (page_obj, content_obj) in enumerate(zip(page_objs, content_objs)): + objects[page_obj] = ( + f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] " + f"/Contents {content_obj} 0 R /Resources << >> >>").encode() + stream = f"{page_colors[i]} rg 10 10 150 150 re f".encode() + objects[content_obj] = (f"<< /Length {len(stream)} >>\nstream\n").encode() \ + + stream + b"\nendstream" + + out = bytearray(b"%PDF-1.4\n") + offsets = {} + for num in sorted(objects): + offsets[num] = len(out) + out += f"{num} 0 obj\n".encode() + objects[num] + b"\nendobj\n" + xref_start = len(out) + total = len(objects) + 1 + out += f"xref\n0 {total}\n".encode() + out += b"0000000000 65535 f \n" + for num in sorted(objects): + out += f"{offsets[num]:010d} 00000 n \n".encode() + out += f"trailer\n<< /Size {total} /Root 1 0 R >>\nstartxref\n{xref_start}\n%%EOF".encode() + path.write_bytes(bytes(out)) + + +# ========================================================================== +# unit +# ========================================================================== + +class ToolRegistry(unittest.TestCase): + def test_names_are_unique_and_prefixed(self): + names = [t["name"] for t in m.TOOLS] + self.assertEqual(len(names), len(set(names))) + self.assertTrue(all(n.startswith("qet_") for n in names)) + + def test_every_schema_is_well_formed(self): + for t in m.TOOLS: + with self.subTest(tool=t["name"]): + s = t["inputSchema"] + self.assertEqual(s["type"], "object") + self.assertTrue(set(s.get("required", [])) <= set(s["properties"]), + "a required key is missing from properties") + self.assertTrue(callable(t["handler"])) + self.assertTrue(t["description"].strip()) + json.dumps(s) # must be serialisable as-is + + def test_expected_tools_exist(self): + self.assertEqual({t["name"] for t in m.TOOLS}, { + "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"}) + + +class EditValidation(unittest.TestCase): + """Every argument error must be raised before QElectroTech is launched. + + tool_edit is called with a binary that does not exist: if validation + were lazy the failure would be 'not an executable', not the ValueError + asserted here. + """ + + def build(self, ops): + return m._build_script(ops, "/tmp/out.qet") + + def test_every_op_generates_a_script(self): + # one minimal valid instance of every op + f = {"op": "add_folio", "id": "f"} + samples = { + "add_folio": [f], + "set_folio_title": [f, {"op": "set_folio_title", "folio": "$f", "title": "t"}], + "add_element": [f, {"op": "add_element", "id": "e", "folio": "$f", "path": "p", "x": 1, "y": 2}], + } + el = [f, {"op": "add_element", "id": "e", "folio": "$f", "path": "p", "x": 1, "y": 2}] + two = el + [{"op": "add_element", "id": "e2", "folio": "$f", "path": "p", "x": 1, "y": 2}] + samples.update({ + "set_position": el + [{"op": "set_position", "folio": "$f", "element": "$e", "x": 1, "y": 1}], + "move_element": el + [{"op": "move_element", "folio": "$f", "element": "$e", "dx": 1, "dy": 1}], + "rotate_element": el + [{"op": "rotate_element", "folio": "$f", "element": "$e", "angle": 90}], + "set_label": el + [{"op": "set_label", "folio": "$f", "element": "$e", "label": "K"}], + "set_info": el + [{"op": "set_info", "folio": "$f", "element": "$e", "key": "k", "value": "v"}], + "add_conductor": two + [{"op": "add_conductor", "folio": "$f", "from": "$e", "from_terminal": 0, + "to": "$e2", "to_terminal": 0}], + "delete_element": el + [{"op": "delete_element", "folio": "$f", "element": "$e"}], + "set_conductor": el + [{"op": "set_conductor", "folio": "$f", "element": "$e", "terminal": 0, + "property": "num", "value": "1"}], + "move_conductor_segment": el + [{"op": "move_conductor_segment", "folio": "$f", + "element": "$e", "terminal": 0, "segment": 1, + "dx": 10, "dy": 0}], + "delete_conductor": el + [{"op": "delete_conductor", "folio": "$f", "element": "$e", "terminal": 0}], + "link_elements": two + [{"op": "link_elements", "folio": "$f", "element": "$e", + "to_folio": "$f", "to": "$e2"}], + "unlink_element": el + [{"op": "unlink_element", "folio": "$f", "element": "$e"}], + "remove_folio": [f, {"op": "remove_folio", "folio": "$f"}], + "set_folio": [f, {"op": "set_folio", "folio": "$f", "property": "author", "value": "a"}], + "add_text": [f, {"op": "add_text", "id": "t", "folio": "$f", "text": "x", "x": 0, "y": 0}], + "set_text": [f, {"op": "set_text", "folio": "$f", "index": 0, "text": "x"}], + "set_text_color": [f, {"op": "set_text_color", "folio": "$f", "index": 0, "color": "#000"}], + "rotate_text": [f, {"op": "rotate_text", "folio": "$f", "index": 0, "angle": 5}], + "delete_text": [f, {"op": "delete_text", "folio": "$f", "index": 0}], + "add_shape": [f, {"op": "add_shape", "folio": "$f", "shape": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 1}], + "set_shape": [f, {"op": "set_shape", "folio": "$f", "index": 0, "property": "fill", "value": "none"}], + "delete_shape": [f, {"op": "delete_shape", "folio": "$f", "index": 0}], + "add_polygon": [f, {"op": "add_polygon", "folio": "$f", + "points": [{"x": 0, "y": 0}, {"x": 1, "y": 0}, {"x": 1, "y": 1}], + "closed": True}], + "set_shape_polygon": [f, {"op": "add_polygon", "id": "poly", "folio": "$f", + "points": [{"x": 0, "y": 0}, {"x": 1, "y": 0}, {"x": 1, "y": 1}], + "closed": True}, + {"op": "set_shape_polygon", "folio": "$f", "index": "$poly", + "points": [{"x": 2, "y": 2}, {"x": 3, "y": 2}, {"x": 3, "y": 3}]}], + "add_path": [f, {"op": "add_path", "folio": "$f", + "nodes": [{"x": 0, "y": 0}, {"x": 1, "y": 0, "kind": "smooth", + "inHandle": {"x": 0.5, "y": 0}, "outHandle": {"x": 1.5, "y": 0}}], + "closed": False}], + "set_shape_path_nodes": [f, {"op": "add_path", "id": "path", "folio": "$f", + "nodes": [{"x": 0, "y": 0}, {"x": 1, "y": 0}], "closed": False}, + {"op": "set_shape_path_nodes", "folio": "$f", "index": "$path", + "nodes": [{"x": 5, "y": 5}, {"x": 6, "y": 5}]}], + "set_shape_closed": [f, {"op": "add_polygon", "id": "poly2", "folio": "$f", + "points": [{"x": 0, "y": 0}, {"x": 1, "y": 0}, {"x": 1, "y": 1}], + "closed": True}, + {"op": "set_shape_closed", "folio": "$f", "index": "$poly2", "closed": False}], + "add_image": [f, {"op": "add_image", "folio": "$f", "file": "/x.png", "x": 0, "y": 0}], + "scale_image": [f, {"op": "scale_image", "folio": "$f", "index": 0, "factor": 2}], + "rotate_image": [f, {"op": "rotate_image", "folio": "$f", "index": 0, "angle": 5}], + "delete_image": [f, {"op": "delete_image", "folio": "$f", "index": 0}], + "add_pdf_page": [f, {"op": "add_pdf_page", "folio": "$f", "file": "/x.pdf", + "page": 1, "dpi": 150, "x": 0, "y": 0}], + "add_element_text": el + [{"op": "add_element_text", "id": "t", "folio": "$f", "element": "$e", + "source": "info", "value": "comment", "x": 1, "y": 2}], + "set_element_text": el + [{"op": "set_element_text", "folio": "$f", "element": "$e", "index": 0, + "property": "x", "value": "5"}], + "delete_element_text": el + [{"op": "delete_element_text", "folio": "$f", "element": "$e", "index": 0}], + "add_terminal_strip": [{"op": "add_terminal_strip", "installation": "a", "location": "b", "name": "c"}], + "remove_terminal_strip": [{"op": "remove_terminal_strip", "strip": 0}], + "add_to_strip": el + [{"op": "add_to_strip", "strip": 0, "folio": "$f", "element": "$e"}], + "group_terminals": [{"op": "group_terminals", "strip": 0, "indices": [0, 1]}], + "bridge_terminals": [{"op": "bridge_terminals", "strip": 0, "indices": [0, 1]}], + "sort_terminal_strip": [{"op": "sort_terminal_strip", "strip": 0}], + "add_autonum": [{"op": "add_autonum", "kind": "conductor", "name": "W", "parts": ["string:W"]}], + "remove_autonum": [{"op": "remove_autonum", "kind": "conductor", "name": "W"}], + "use_conductor_autonum": [f, {"op": "use_conductor_autonum", "folio": "$f", "name": "W"}], + "use_element_autonum": [{"op": "use_element_autonum", "name": "EL"}], + "insert_folio": [{"op": "insert_folio", "id": "i", "position": 0}], + "element_geometry": el + [{"op": "element_geometry", "folio": "$f", "element": "$e"}], + "undo": [{"op": "undo"}], + "redo": [{"op": "redo"}], + "search_and_replace": [{"op": "search_and_replace", "kind": "text", "field": "", + "pattern": "x", "replacement": "y", + "regex": False, "case_sensitive": True}], + "set_project_title": [{"op": "set_project_title", "title": "T"}], + "set_folio_border": [f, {"op": "set_folio_border", "folio": "$f", "property": "columns", "value": "10"}], + "embed_title_block_template": [{"op": "embed_title_block_template", "name": "default"}], + "duplicate_elements": el + [{"op": "duplicate_elements", "id": "d", "folio": "$f", + "elements": ["$e"], "to_folio": "$f", "x": 50, "y": 50}], + "number_element": el + [{"op": "number_element", "folio": "$f", "element": "$e"}], + "add_table": [f, {"op": "add_table", "id": "t", "folio": "$f", "kind": "nomenclature", + "name": "BOM", "query": "SELECT label FROM element_nomenclature_view"}], + "set_table_position": [f, {"op": "add_table", "id": "t", "folio": "$f", "kind": "nomenclature", + "name": "BOM", "query": "SELECT label FROM element_nomenclature_view"}, + {"op": "set_table_position", "folio": "$f", "table": "$t", "x": 1, "y": 1}], + "delete_table": [f, {"op": "add_table", "id": "t", "folio": "$f", "kind": "nomenclature", + "name": "BOM", "query": "SELECT label FROM element_nomenclature_view"}, + {"op": "delete_table", "folio": "$f", "table": "$t"}], + "link_plc_io": two + [{"op": "link_plc_io", "folio": "$f", "element": "$e", + "to_folio": "$f", "to": "$e2", "io_index": 0}], + "add_plc_io": el + [{"op": "add_plc_io", "folio": "$f", "element": "$e", + "type": "entree_digitale", "address": "1.0", + "function": "f", "comment": "c"}], + "set_plc_io": el + [{"op": "set_plc_io", "folio": "$f", "element": "$e", + "index": 0, "property": "address", "value": "1.1"}], + "remove_plc_io": el + [{"op": "remove_plc_io", "folio": "$f", "element": "$e", "index": 0}], + }) + self.assertEqual(set(samples), set(m.OPS), + "an op has no sample here: add one so it is exercised") + for name, ops in samples.items(): + with self.subTest(op=name): + script = self.build(ops) + self.assertIn("qet.save(", script) + self.assertIn(m.OPS[name][0] or "addFolio", script) + + def test_unknown_op(self): + with self.assertRaisesRegex(ValueError, "unknown op"): + self.build([{"op": "draw_a_nice_diagram"}]) + + def test_missing_argument(self): + with self.assertRaisesRegex(ValueError, "missing 'path'"): + self.build([{"op": "add_element", "folio": 0, "x": 1, "y": 2}]) + + def test_dangling_reference(self): + with self.assertRaisesRegex(ValueError, "no earlier"): + self.build([{"op": "set_label", "folio": "$nope", "element": "$x", "label": "a"}]) + + def test_id_rules(self): + for bad in ("$f", "", 3): + with self.subTest(id=bad): + with self.assertRaises(ValueError): + self.build([{"op": "add_folio", "id": bad}]) + with self.assertRaisesRegex(ValueError, "already used"): + self.build([{"op": "add_folio", "id": "f"}, {"op": "add_folio", "id": "f"}]) + + def test_enumerated_arguments_are_checked_up_front(self): + cases = [ + {"op": "set_conductor", "folio": 0, "element": "x", "terminal": 0, "property": "voltage", "value": "1"}, + {"op": "set_folio", "folio": 0, "property": "version", "value": "1"}, + {"op": "set_shape", "folio": 0, "index": 0, "property": "shadow", "value": "1"}, + {"op": "add_shape", "folio": 0, "shape": "hexagon", "x1": 0, "y1": 0, "x2": 1, "y2": 1}, + {"op": "add_autonum", "kind": "wire", "name": "a", "parts": ["string:W"]}, + {"op": "set_element_text", "folio": 0, "element": "x", "index": 0, "property": "shadow", "value": "1"}, + {"op": "add_element_text", "folio": 0, "element": "x", "source": "magic", "value": "v", "x": 0, "y": 0}, + {"op": "set_folio_border", "folio": 0, "property": "thickness", "value": "3"}, + ] + for c in cases: + with self.subTest(op=c["op"], bad=c.get("property") or c.get("shape") or c.get("kind")): + with self.assertRaises(ValueError): + self.build([c]) + + def test_version_is_not_a_settable_folio_property(self): + """setFolioProperty('version') reported success and was overwritten by + the file-format stamp. It must stay refused on every layer.""" + self.assertNotIn("version", m.FOLIO_PROPERTIES) + + def test_list_argument_must_be_list_of_strings(self): + with self.assertRaisesRegex(ValueError, "list of strings"): + self.build([{"op": "add_autonum", "kind": "conductor", "name": "a", "parts": "string:W"}]) + + def test_indexed_references_and_element_lists(self): + script = self.build([ + {"op": "add_folio", "id": "f"}, + {"op": "add_element", "id": "a", "folio": "$f", "path": "p", "x": 0, "y": 0}, + {"op": "add_element", "id": "b", "folio": "$f", "path": "p", "x": 9, "y": 0}, + {"op": "duplicate_elements", "id": "c", "folio": "$f", "elements": ["$a", "$b"], + "to_folio": "$f", "x": 5, "y": 5}, + {"op": "set_label", "folio": "$f", "element": "$c[1]", "label": "X"}]) + self.assertIn('R["c"][1]', script) + self.assertIn('[R["a"], R["b"]]', script) + for bad in ("$c[", "$c[x]", "$nope[0]"): + with self.subTest(ref=bad): + with self.assertRaises(ValueError): + self.build([{"op": "add_folio", "id": "f"}, + {"op": "set_label", "folio": "$f", "element": bad, "label": "X"}]) + for bad in ([], "a", [1, 2]): + with self.subTest(elements=bad): + with self.assertRaises(ValueError): + self.build([{"op": "add_folio", "id": "f"}, + {"op": "duplicate_elements", "folio": "$f", "elements": bad, + "to_folio": "$f", "x": 0, "y": 0}]) + + def test_an_empty_object_result_is_a_failure(self): + """elementGeometry returns {} for an element it cannot find. An empty + object has no .length, so the empty-list test did not cover it.""" + out = m._MARKER + json.dumps(dict(kind="op", index=0, op="element_geometry", id=None, result={})) + self.assertFalse(m._parse_script_output(out)["operations"][0]["succeeded"]) + out = m._MARKER + json.dumps(dict(kind="op", index=0, op="element_geometry", id=None, + result={"x": 0, "y": 0})) + self.assertTrue(m._parse_script_output(out)["operations"][0]["succeeded"]) + script = m._build_script([{"op": "add_folio"}], "/o") + self.assertIn("Object.keys(", script) + + def test_an_empty_list_result_is_a_failure(self): + """duplicateElements returns [] on failure, which the success check + did not recognise: neither falsy in JavaScript's === comparisons nor + equal to False in Python.""" + out = m._MARKER + json.dumps(dict(kind="op", index=0, op="duplicate_elements", id="d", result=[])) + self.assertFalse(m._parse_script_output(out)["operations"][0]["succeeded"]) + # the JavaScript side must not rely on Array.isArray: QJSEngine returns + # an empty QStringList as an array-like wrapper for which it is false + script = m._build_script([{"op": "add_folio"}], "/o") + self.assertIn(".length === 0", script) + self.assertNotIn("Array.isArray", script) + + def test_number_arguments_reject_bool_and_text(self): + for bad in (True, "1", None): + with self.subTest(x=bad): + with self.assertRaises(ValueError): + self.build([{"op": "add_folio", "id": "f"}, + {"op": "add_text", "folio": "$f", "text": "t", "x": bad, "y": 0}]) + + def test_string_values_are_escaped_into_the_script(self): + nasty = 'a"b\\c\n
' + script = self.build([{"op": "add_folio", "id": "f"}, + {"op": "set_folio_title", "folio": "$f", "title": nasty}]) + # the literal must be valid JSON, so JavaScript reads exactly what was sent + literal = re.search(r'setFolioTitle\(R\["f"\], (".*?")\)', script, re.S).group(1) + self.assertEqual(json.loads(literal), nasty) + + def test_edit_refuses_in_place_and_empty(self): + with tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) / "a.qet" + p.write_text("") + with self.assertRaisesRegex(ValueError, "differ from project"): + m.tool_edit("/nonexistent", str(p), [{"op": "add_folio"}], str(p)) + with self.assertRaisesRegex(ValueError, "non-empty"): + m.tool_edit("/nonexistent", str(p), [], str(Path(tmp) / "o.qet")) + + +class ResultParsing(unittest.TestCase): + def line(self, **kw): + return m._MARKER + json.dumps(kw) + + def test_zero_is_a_valid_result(self): + """0 == False in Python. A valid first index (first text, first folio + of an empty project) was read as failure and marked a successful edit + ok=False.""" + out = self.line(kind="op", index=0, op="add_text", id="t", result=0) + rec = m._parse_script_output(out)["operations"][0] + self.assertTrue(rec["succeeded"]) + + def test_failures_are_recognised(self): + for bad in (False, "", -1, None): + with self.subTest(result=bad): + out = self.line(kind="op", index=0, op="x", id=None, result=bad) + self.assertFalse(m._parse_script_output(out)["operations"][0]["succeeded"]) + + def test_true_and_strings_succeed(self): + for good in (True, "{uuid}", 1, 7): + with self.subTest(result=good): + out = self.line(kind="op", index=0, op="x", id=None, result=good) + self.assertTrue(m._parse_script_output(out)["operations"][0]["succeeded"]) + + def test_noise_and_garbage_are_ignored(self): + text = "SQLite version\n" + m._MARKER + "{not json\nplain\n" + \ + self.line(kind="save", result=True, stopped_early=False) + parsed = m._parse_script_output(text) + self.assertTrue(parsed["saved"]) + self.assertEqual(parsed["operations"], []) + + +class TerminalOrder(unittest.TestCase): + """QElectroTech indexes terminals top-to-bottom then left-to-right, not in + file order. Getting it wrong wires the wrong end of a coil with no error; + 619 of the 837 shipped elements with named terminals list them + differently from how they are indexed.""" + + def nodes(self, *specs): + return [ET.Element("terminal", {"name": n, "x": str(x), "y": str(y)}) for n, x, y in specs] + + def test_sorted_by_y_then_x(self): + ordered, amb = m._terminals_in_index_order( + self.nodes(("A2", 0, 20), ("A1", 0, -20))) + self.assertEqual([t.get("name") for t in ordered], ["A1", "A2"]) + self.assertFalse(amb) + + def test_left_to_right_within_a_row(self): + ordered, _ = m._terminals_in_index_order( + self.nodes(("R", 30, 0), ("L", -30, 0), ("M", 0, 0))) + self.assertEqual([t.get("name") for t in ordered], ["L", "M", "R"]) + + def test_y_dominates_x(self): + ordered, _ = m._terminals_in_index_order( + self.nodes(("lowleft", -50, 10), ("highright", 50, -10))) + self.assertEqual([t.get("name") for t in ordered], ["highright", "lowleft"]) + + def test_ties_are_flagged_not_hidden(self): + _, amb = m._terminals_in_index_order(self.nodes(("a", 5, 5), ("b", 5, 5))) + self.assertTrue(amb) + + def test_numeric_not_lexical(self): + ordered, _ = m._terminals_in_index_order( + self.nodes(("nine", 0, 9), ("ten", 0, 10), ("minus", 0, -10))) + self.assertEqual([t.get("name") for t in ordered], ["minus", "nine", "ten"]) + + @unittest.skipUnless(have_elements, "set QET_ELEMENTS") + def test_real_element_disagrees_with_file_order(self): + f = Path(ELEMENTS) / COIL.replace("common://", "") + root = ET.parse(f).getroot() + file_order = [t.get("name") for t in root.iter("terminal")] + ordered, _ = m._terminals_in_index_order(list(root.iter("terminal"))) + self.assertEqual(file_order, ["A2", "A1"]) + self.assertEqual([t.get("name") for t in ordered], ["A1", "A2"]) + + +class ElementBuild(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.out = str(Path(self.tmp.name) / "e.elmt") + self.ok = dict(names={"en": "x"}, + parts=[{"type": "line", "x1": 0, "y1": 0, "x2": 10, "y2": 0}], + terminals=[{"x": 0, "y": 0, "orientation": "n"}]) + + def tearDown(self): + self.tmp.cleanup() + + def build(self, **over): + return m.tool_element_build(self.out, **{**self.ok, **over}) + + def test_rejections(self): + cases = { + "unknown part": dict(parts=[{"type": "squiggle"}]), + "missing key": dict(parts=[{"type": "rect", "x": 0, "y": 0, "width": 5}]), + "typo'd key": dict(parts=[{"type": "circle", "x": 0, "y": 0, "diametre": 5}]), + "bad orientation": dict(terminals=[{"x": 0, "y": 0, "orientation": "up"}]), + "no names": dict(names={}), + "bad link_type": dict(link_type="widget"), + "connectable, no terminals": dict(terminals=[]), + "one-point polygon": dict(parts=[{"type": "polygon", "points": [[0, 0]]}]), + "text coordinate": dict(parts=[{"type": "line", "x1": "left", "y1": 0, "x2": 1, "y2": 0}]), + } + for label, over in cases.items(): + with self.subTest(label): + with self.assertRaises(ValueError): + self.build(**over) + self.assertFalse(Path(self.out).exists(), "nothing may be written on refusal") + + def test_an_unexpected_key_on_a_valid_part_is_refused(self): + """Distinct from a missing key: the part below is complete, and only + the extra attribute is wrong. Without this the 'allowed keys' rule is + never exercised -- the typo case above is refused for lacking + 'diameter', which passes even if unexpected keys are accepted.""" + with self.assertRaisesRegex(ValueError, "unexpected 'colour'"): + self.build(parts=[{"type": "rect", "x": 0, "y": 0, "width": 5, "height": 5, + "colour": "red"}]) + self.assertFalse(Path(self.out).exists()) + + def test_declared_box_contains_the_drawing(self): + """The header is a containment constraint, not a formula, and the + assertion behind it is what stops a clipped element.""" + for parts in ([{"type": "rect", "x": 100, "y": 100, "width": 50, "height": 50}], + [{"type": "rect", "x": -200, "y": -150, "width": 40, "height": 30}], + [{"type": "circle", "x": -25, "y": -25, "diameter": 50}]): + with self.subTest(parts=parts[0]["type"]): + r = self.build(parts=parts) + x0, y0, x1, y1 = r["bbox"] + self.assertLessEqual(-r["hotspot_x"], x0) + self.assertLessEqual(-r["hotspot_y"], y0) + self.assertGreaterEqual(r["width"] - r["hotspot_x"], x1) + self.assertGreaterEqual(r["height"] - r["hotspot_y"], y1) + + def test_reports_terminal_index_order_not_input_order(self): + r = self.build(parts=[{"type": "line", "x1": 0, "y1": -20, "x2": 0, "y2": 20}], + terminals=[{"x": 0, "y": 20, "orientation": "s", "name": "low"}, + {"x": 0, "y": -20, "orientation": "n", "name": "high"}]) + self.assertEqual(r["terminal_index_order"], ["high", "low"]) + + def test_output_reads_back(self): + r = self.build(names={"en": "Coil", "fr": "Bobine"}) + self.assertEqual(r["verified"]["names"], {"en": "Coil", "fr": "Bobine"}) + root = ET.parse(self.out).getroot() + self.assertEqual(root.tag, "definition") + self.assertEqual(root.get("link_type"), "simple") + + def test_special_characters_survive(self): + r = self.build(names={"en": 'Coil "A" & ', "fr": "Résistance"}) + self.assertEqual(r["verified"]["names"]["fr"], "Résistance") + self.assertEqual(r["verified"]["names"]["en"], 'Coil "A" & ') + + +class ElementSearch(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + m._ELEMENT_INDEX.clear() + + def tearDown(self): + self.tmp.cleanup() + + def put(self, rel, names, link="simple", terminals=((0, -10, "n"), (0, 10, "s")), kind=""): + p = self.root / rel + p.parent.mkdir(parents=True, exist_ok=True) + kinds = f'{kind}' if kind else "" + ns = "".join(f'{n}' for l, n in names.items()) + ts = "".join(f'' + for i, (x, y, o) in enumerate(terminals)) + p.write_text(f'{ns}{kinds}' + f'{ts}', encoding="utf-8") + + def test_accents_and_case_are_ignored_across_languages(self): + self.put("a/res.elmt", {"en": "Resistor", "fr": "Résistance", "de": "Widerstand"}) + for q in ("resistance", "RÉSISTANCE", "widerstand", "resistor"): + with self.subTest(q=q): + self.assertEqual(m.tool_element_search(str(self.root), q)["total_matches"], 1) + + def test_every_word_must_match(self): + self.put("a/x.elmt", {"en": "Big red coil"}) + self.assertEqual(m.tool_element_search(str(self.root), "red coil")["total_matches"], 1) + self.assertEqual(m.tool_element_search(str(self.root), "red fuse")["total_matches"], 0) + + def test_filters(self): + self.put("a/m.elmt", {"en": "Coil"}, link="master", kind="coil") + self.put("a/s.elmt", {"en": "Contact"}, link="slave", terminals=((0, 0, "n"),) * 1) + self.put("a/t.elmt", {"en": "Big"}, terminals=[(0, i, "n") for i in range(5)]) + g = lambda **k: {r["name"] for r in m.tool_element_search(str(self.root), **k)["results"]} + self.assertEqual(g(link_type="master"), {"Coil"}) + self.assertEqual(g(min_terminals=5), {"Big"}) + self.assertEqual(g(max_terminals=1), {"Contact"}) + self.assertEqual(g(kind="coil"), {"Coil"}) + + def test_result_is_placeable_path_and_index_ordered_terminals(self): + self.put("dir/x.elmt", {"en": "X"}, terminals=((0, 20, "s"), (0, -20, "n"))) + r = m.tool_element_search(str(self.root), "x")["results"][0] + self.assertEqual(r["path"], "common://dir/x.elmt") + self.assertEqual(r["terminal_names"], ["t1", "t0"]) # t1 is at y=-20: index 0 + + def test_new_file_is_found_without_a_manual_reindex(self): + """A symbol written by qet_element_build must be searchable at once.""" + self.put("a/one.elmt", {"en": "One"}) + self.assertEqual(m.tool_element_search(str(self.root), "two")["total_matches"], 0) + self.put("a/two.elmt", {"en": "Two"}) + self.assertEqual(m.tool_element_search(str(self.root), "two")["total_matches"], 1) + + def test_exact_name_ranks_first(self): + self.put("a/long.elmt", {"en": "Remanence coil, latching"}) + self.put("a/short.elmt", {"en": "Coil"}) + self.assertEqual(m.tool_element_search(str(self.root), "coil")["results"][0]["name"], "Coil") + + def test_bad_arguments(self): + with self.assertRaises(ValueError): + m.tool_element_search("/no/such/dir") + with self.assertRaises(ValueError): + m.tool_element_search(str(self.root), link_type="widget") + with self.assertRaises(ValueError): + m.tool_element_search(str(self.root), limit=0) + + def test_unparseable_files_are_skipped_not_fatal(self): + (self.root / "bad.elmt").write_text("' + for x, y, t in texts) + shp = "".join(f'' + f'' + f'' + for a, b, c, d, pc in shapes) + img = "".join(f'' for x, y, s in images) + st = "".join(f'' + f'{n}' + f'' for u, n in strips) + cond = "".join(f'' + for a, b, n in conductors) + xml = (f'' + f'{cond}{inputs}' + f'{img}{shp}' + f'{st}') + p = self.dir / name + p.write_text(xml) + return str(p) + + def test_identical_projects_have_an_empty_diff_everywhere(self): + a = self.project("a.qet", texts=[(1, 2, "hi")], shapes=[(0, 0, 5, 5, "#000")], + images=[(3, 3, 1)], strips=[("{u}", "X1")]) + d = m.tool_diff(a, a) + for k in ("texts", "shapes", "images", "terminal_strips"): + with self.subTest(section=k): + self.assertFalse(d[k]["added"] or d[k]["removed"] or d[k]["changed"]) + self.assertEqual(d["folios"]["changed"], []) + + def test_edited_text_reads_as_removed_plus_added(self): + a = self.project("a.qet", texts=[(1, 2, "note")]) + b = self.project("b.qet", texts=[(1, 2, "note EDITED")]) + d = m.tool_diff(a, b)["texts"] + self.assertEqual([t["text"] for t in d["removed"]], ["note"]) + self.assertEqual([t["text"] for t in d["added"]], ["note EDITED"]) + + def test_shape_restyle_is_a_change_to_that_item(self): + a = self.project("a.qet", shapes=[(0, 0, 5, 5, "#000000")]) + b = self.project("b.qet", shapes=[(0, 0, 5, 5, "#ff0000")]) + d = m.tool_diff(a, b)["shapes"] + self.assertEqual(d["added"], []) + self.assertEqual(d["changed"][0]["changed"]["line_color"], ["#000000", "#ff0000"]) + + def test_image_rescale_is_a_change(self): + a = self.project("a.qet", images=[(1, 1, 2)]) + b = self.project("b.qet", images=[(1, 1, 3)]) + self.assertEqual(m.tool_diff(a, b)["images"]["changed"][0]["changed"]["scale"], ["2", "3"]) + + def test_folio_author_change(self): + a = self.project("a.qet", author="A") + b = self.project("b.qet", author="B") + self.assertEqual(m.tool_diff(a, b)["folios"]["changed"][0]["changed"]["author"], ["A", "B"]) + + def test_the_file_version_stamp_is_not_a_change(self): + """QElectroTech rewrites every folio's version attribute on save; + comparing it made every folio of a re-saved project look edited.""" + a = self.project("a.qet", version="0.80c") + b = self.project("b.qet", version="0.200.1-dev") + self.assertEqual(m.tool_diff(a, b)["folios"]["changed"], []) + + def test_element_text_field_moves_and_restyles_are_reported(self): + def proj(x, size, frame, extra=""): + return (f'' + f'KM1' + f'label{extra}' + f'') + a = self.dir / "a.qet" + b = self.dir / "b.qet" + a.write_text(proj(30, 9, "false")) + b.write_text(proj(60, 14, "true", 'NEW' + '')) + d = m.tool_diff(str(a), str(b))["element_texts"] + self.assertEqual(len(d["added"]), 1) + self.assertEqual(d["changed"][0]["changed"], + {"x": ["30", "60"], "size": ["9", "14"], "frame": ["false", "true"]}) + self.assertEqual(d["changed"][0]["item"]["bound_to"], "label") + self.assertFalse(m.tool_diff(str(a), str(a))["element_texts"]["changed"]) + + def test_project_title_and_folio_frame_changes_are_reported(self): + def proj(title, cols): + return (f'') + a, b = self.dir / "a.qet", self.dir / "b.qet" + a.write_text(proj("Old", 17)) + b.write_text(proj("New", 10)) + d = m.tool_diff(str(a), str(b)) + self.assertEqual(d["project"]["changed"], {"title": ["Old", "New"]}) + self.assertEqual(d["folios"]["changed"][0]["changed"], {"cols": ["17", "10"]}) + self.assertEqual(m.tool_diff(str(a), str(a))["project"]["changed"], {}) + + def test_strip_added(self): + a = self.project("a.qet") + b = self.project("b.qet", strips=[("{u}", "X9")]) + self.assertEqual(m.tool_diff(a, b)["terminal_strips"]["added"], ["X9"]) + + def test_text_html_is_reduced_to_visible_text(self): + self.assertEqual(m._plain_text("

a & b

"), "a & b") + + +class ConductorKey(unittest.TestCase): + """The key must survive a save. QElectroTech renumbers the folio-scoped + terminal ids on every write, so keying on the raw pair made every + conductor of an untouched folio read as removed and re-added.""" + + def diagram(self, terminals, conductors): + d = ET.Element("diagram") + for uuid, tid, x, y in terminals: + el = ET.SubElement(d, "element", {"uuid": uuid}) + ET.SubElement(el, "terminal", {"id": tid, "x": str(x), "y": str(y), "orientation": "n"}) + for a, b in conductors: + ET.SubElement(d, "conductor", {"terminal1": a, "terminal2": b}) + return d + + def keys(self, d): + ix = m._terminal_index(d) + return {m._conductor_key(1, c, ix) for c in d.iter("conductor")} + + def test_renumbering_the_ids_does_not_change_the_key(self): + before = self.diagram([("{a}", "1", 0, 0), ("{b}", "16", 0, 0)], [("1", "16")]) + after = self.diagram([("{a}", "34", 0, 0), ("{b}", "15", 0, 0)], [("34", "15")]) + self.assertEqual(self.keys(before), self.keys(after)) + + def test_end_order_does_not_matter(self): + one = self.diagram([("{a}", "1", 0, 0), ("{b}", "2", 0, 0)], [("1", "2")]) + two = self.diagram([("{a}", "1", 0, 0), ("{b}", "2", 0, 0)], [("2", "1")]) + self.assertEqual(self.keys(one), self.keys(two)) + + def test_different_conductors_get_different_keys(self): + d = self.diagram([("{a}", "1", 0, 0), ("{b}", "2", 0, 0), ("{c}", "3", 0, 0)], + [("1", "2"), ("1", "3")]) + self.assertEqual(len(self.keys(d)), 2) + + def test_elements_without_uuid_stay_apart_and_are_marked(self): + """Keying them on geometry collapsed nine distinct conductors onto + one key, which was worse than the instability being fixed.""" + d = self.diagram([("", "1", 0, 0), ("", "2", 0, 0), ("", "3", 0, 0)], + [("1", "2"), ("2", "3"), ("1", "3")]) + keys = self.keys(d) + self.assertEqual(len(keys), 3) + self.assertTrue(all("#" in k for k in keys)) + + def test_current_format_uses_element_and_terminal(self): + """A terminal uuid belongs to the definition, so two coils of one type + share it; the (instance, terminal) pair is the identity.""" + d = ET.Element("diagram") + for a, b in (("{e1}", "{e2}"), ("{e1}", "{e3}")): + ET.SubElement(d, "conductor", {"element1": a, "terminal1": "{T}", + "element2": b, "terminal2": "{T}"}) + self.assertEqual(len(self.keys(d)), 2) + + +class ChecksDefinition(unittest.TestCase): + def test_all_checks_are_read_only_selects(self): + for name, c in m.CHECKS.items(): + with self.subTest(check=name): + self.assertRegex(c["sql"].lstrip().upper(), r"^(SELECT|WITH)\b") + self.assertNotRegex(c["sql"].upper(), r"\b(DELETE|UPDATE|INSERT|DROP|ALTER)\b") + self.assertIn(c["severity"], ("error", "warning", "info")) + self.assertTrue(c["note"]) + + def test_text_comparisons_treat_null_as_empty(self): + """A value never set is NULL for an element placed in this session + and '' for one loaded from a file; `col = ''` matches only the + latter. Every comparison must go through COALESCE.""" + for name, c in m.CHECKS.items(): + for col in ("label", "manufacturer_reference", "wire_number"): + for m_ in re.finditer(rf"(?)\s*''", c["sql"]): + self.fail(f"{name}: bare {col} comparison at {m_.start()} misses NULL") + + def test_unknown_check_and_bad_sample(self): + with tempfile.NamedTemporaryFile(suffix=".qet") as f: + with self.assertRaises(ValueError): + m.tool_check("/x", f.name, checks=["nope"]) + with self.assertRaises(ValueError): + m.tool_check("/x", f.name, sample=-1) + + +class QueryGuard(unittest.TestCase): + def test_obvious_writes_are_refused_before_launch(self): + with tempfile.NamedTemporaryFile(suffix=".qet") as f: + for sql in ("DELETE FROM element", "DROP TABLE element", + "UPDATE element SET x=1", "PRAGMA table_info(element)", + "INSERT INTO element VALUES (1)"): + with self.subTest(sql=sql): + with self.assertRaisesRegex(ValueError, "read-only"): + m.tool_query("/nonexistent", f.name, sql) + + def test_missing_project(self): + with self.assertRaisesRegex(ValueError, "no such project"): + m.tool_query("/x", "/no/such.qet", "SELECT 1") + + +class ProjectNewValidation(unittest.TestCase): + def test_refuses_overwrite_and_bad_arguments(self): + with tempfile.TemporaryDirectory() as tmp: + existing = Path(tmp) / "a.qet" + existing.write_text("x") + with self.assertRaisesRegex(ValueError, "already exists"): + m.tool_project_new("/x", str(existing), "t") + new = str(Path(tmp) / "n.qet") + for kw in ({"folios": True}, {"folios": "2"}, {"folios": 500}, {"title": " "}): + with self.subTest(**kw): + with self.assertRaises(ValueError): + m.tool_project_new("/x", new, **({"title": "t"} | kw)) + self.assertFalse(Path(new).exists()) + + +class ReadTools(unittest.TestCase): + def test_project_info_and_scan_on_a_fixture(self): + with tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) / "a.qet" + p.write_text('' + '' + '' + '') + info = m.tool_project_info(str(p)) + self.assertEqual((info["title"], info["folio_count"]), ("T", 1)) + scan = m.tool_scan(tmp, "conductor", "cable") + self.assertEqual((scan["files"], scan["total"], scan["non_empty"]), (1, 1, 1)) + # (value, how many nodes carry it) + self.assertEqual([tuple(v) for v in scan["distinct_values"]], [("C1", 1)]) + + +# ========================================================================== +# protocol +# ========================================================================== + +class Protocol(unittest.TestCase): + def rpc(self, *messages): + proc = subprocess.run([sys.executable, str(HERE / "qet_mcp.py")], + input="\n".join(json.dumps(x) for x in messages) + "\n", + capture_output=True, text=True, timeout=30) + self.assertEqual(proc.returncode, 0, proc.stderr) + out = [json.loads(line) for line in proc.stdout.splitlines() if line.strip()] + return {o["id"]: o for o in out if "id" in o}, proc + + def test_handshake_list_and_call_over_real_stdio(self): + with tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) / "a.qet" + p.write_text('') + replies, proc = self.rpc( + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + {"jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": {"name": "qet_project_info", "arguments": {"path": str(p)}}}, + {"jsonrpc": "2.0", "id": 4, "method": "ping"}) + self.assertEqual(replies[1]["result"]["serverInfo"]["name"], "qet-mcp") + self.assertEqual(len(replies[2]["result"]["tools"]), len(m.TOOLS)) + body = json.loads(replies[3]["result"]["content"][0]["text"]) + self.assertEqual(body["title"], "Via stdio") + self.assertEqual(replies[4]["result"], {}) + self.assertEqual(len(replies), 4, "a notification must not get a reply") + + def test_stdout_carries_only_protocol(self): + _, proc = self.rpc({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) + for line in proc.stdout.splitlines(): + if line.strip(): + json.loads(line) + + def test_tool_errors_are_reported_not_thrown(self): + replies, _ = self.rpc({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "qet_project_info", "arguments": {"path": "/no/such.qet"}}}) + self.assertTrue(replies[1]["result"]["isError"]) + + def test_unknown_tool_and_method(self): + replies, _ = self.rpc( + {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "nope"}}, + {"jsonrpc": "2.0", "id": 2, "method": "no/such"}) + self.assertEqual(replies[1]["error"]["code"], -32602) + self.assertEqual(replies[2]["error"]["code"], -32601) + + def test_list_flag(self): + out = subprocess.run([sys.executable, str(HERE / "qet_mcp.py"), "--list"], + capture_output=True, text=True, timeout=30).stdout + for t in m.TOOLS: + self.assertIn(t["name"], out) + + +# ========================================================================== +# integration +# ========================================================================== + +class Sandbox: + """A scratch directory plus the binary/collection arguments.""" + + def __init__(self): + self.tmp = tempfile.TemporaryDirectory() + self.dir = Path(self.tmp.name) + + def p(self, name): + return str(self.dir / name) + + def new(self, title="T", folios=1, **kw): + r = m.tool_project_new(BINARY, self.p(f"{title}.qet"), title=title, folios=folios, + overwrite=True, **kw) + assert r["ok"], r + return r["output"] + + def edit(self, project, ops, out="out.qet"): + return m.tool_edit(BINARY, project, ops, self.p(out), elements_dir=ELEMENTS) + + def close(self): + self.tmp.cleanup() + + +@needs_elements +class Integration(unittest.TestCase): + def setUp(self): + self.sb = Sandbox() + + def tearDown(self): + self.sb.close() + + def ok(self, r): + self.assertTrue(r["ok"], json.dumps({k: r.get(k) for k in ("hint", "operations")}, default=str)[:600]) + return r + + # ---- project_new ---- + + def test_new_project_reads_back(self): + r = m.tool_project_new(BINARY, self.sb.p("n.qet"), title='A "b" & ', + folios=["Power", "Control"], author="Me") + self.assertTrue(r["ok"]) + self.assertEqual(r["project"]["title"], 'A "b" & ') + self.assertEqual([f["title"] for f in r["project"]["folios"]], ["Power", "Control"]) + + def test_new_project_with_zero_folios(self): + r = m.tool_project_new(BINARY, self.sb.p("z.qet"), title="Z", folios=0) + self.assertTrue(r["ok"]) + self.assertEqual(r["project"]["folio_count"], 0) + + # ---- edit: geometry, wiring, labels ---- + + def test_place_wire_label_number_and_verify_by_diff(self): + base = self.sb.new() + ops = [ + {"op": "add_autonum", "kind": "conductor", "name": "W", "parts": ["string:W", "unit:1:1"]}, + {"op": "use_conductor_autonum", "folio": 0, "name": "W"}, + {"op": "add_element", "id": "a", "folio": 0, "path": COIL, "x": 100, "y": 100}, + {"op": "add_element", "id": "b", "folio": 0, "path": COIL, "x": 300, "y": 100}, + {"op": "add_conductor", "folio": 0, "from": "$a", "from_terminal": 0, "to": "$b", "to_terminal": 0}, + {"op": "set_label", "folio": 0, "element": "$a", "label": "KM1"}, + {"op": "rotate_element", "folio": 0, "element": "$b", "angle": 90}, + ] + r = self.ok(self.sb.edit(base, ops)) + self.assertTrue(all(o["succeeded"] for o in r["operations"])) + d = r["diff"] + self.assertEqual(len(d["elements"]["added"]), 2) + self.assertEqual(len(d["conductors"]["added"]), 1) + self.assertEqual(d["conductors"]["removed"], []) + nums = [c["num"] for c in m.tool_conductors(r["output"])["conductors"]] + self.assertEqual(nums, ["W1"]) + + def test_noop_edit_has_no_conductor_churn(self): + """Re-saving renumbers the file's terminal ids; the diff must not + report the untouched conductors as removed and re-added.""" + if not have_examples: + self.skipTest("set QET_EXAMPLES") + src = shutil.copy(Path(EXAMPLES) / "ArduinoLCD.qet", self.sb.p("in.qet")) + r = self.ok(self.sb.edit(src, [{"op": "add_folio"}])) + c = r["diff"]["conductors"] + self.assertEqual((len(c["added"]), len(c["removed"])), (0, 0)) + self.assertEqual(c["before"], c["after"]) + + def test_failed_operation_stops_the_run_and_reports_it(self): + base = self.sb.new() + r = self.sb.edit(base, [ + {"op": "add_folio", "id": "f"}, + {"op": "add_element", "id": "a", "folio": "$f", "path": COIL, "x": 0, "y": 0}, + {"op": "add_conductor", "folio": "$f", "from": "$a", "from_terminal": 99, "to": "$a", "to_terminal": 0}, + {"op": "set_label", "folio": "$f", "element": "$a", "label": "NEVER"}, + ]) + self.assertFalse(r["ok"]) + self.assertTrue(r["stopped_early"]) + self.assertEqual(len(r["operations"]), 3) + self.assertIn("script", r, "the generated script is returned for debugging") + + def test_first_index_zero_is_not_a_failure(self): + """add_folio on an empty project and the first text both return 0.""" + base = m.tool_project_new(BINARY, self.sb.p("e.qet"), title="E", folios=0)["output"] + r = self.ok(self.sb.edit(base, [ + {"op": "add_folio", "id": "f"}, + {"op": "add_text", "id": "t", "folio": "$f", "text": "hi", "x": 5, "y": 5}])) + self.assertEqual([o["result"] for o in r["operations"]], [0, 0]) + + def test_two_potentials_are_refused_not_hung(self): + """ConductorCreator opens a modal dialog here, and headless nobody + can answer it: with the guard removed this call never returns.""" + base = self.sb.new() + r = m.tool_edit(BINARY, base, [ + {"op": "add_folio", "id": "f"}, + *[{"op": "add_element", "id": f"e{i}", "folio": "$f", "path": COIL, "x": 100 + i * 200, "y": 100} + for i in range(4)], + {"op": "add_conductor", "folio": "$f", "from": "$e0", "from_terminal": 0, "to": "$e1", "to_terminal": 0}, + {"op": "add_conductor", "folio": "$f", "from": "$e2", "from_terminal": 0, "to": "$e3", "to_terminal": 0}, + {"op": "add_conductor", "folio": "$f", "from": "$e0", "from_terminal": 0, "to": "$e2", "to_terminal": 0}, + ], self.sb.p("conflict.qet"), elements_dir=ELEMENTS, timeout=60) + self.assertFalse(r.get("timed_out"), "the call hung on a modal dialog") + self.assertFalse(r["ok"]) + self.assertEqual(r["operations"][-1]["result"], False) + + # ---- edit: conductor properties, potentials ---- + + def test_conductor_property_applies_to_the_whole_potential(self): + base = self.sb.new() + hub = [{"op": "add_folio", "id": "f"}, + *[{"op": "add_element", "id": f"e{i}", "folio": "$f", "path": COIL, "x": 100 + i * 200, "y": 100} + for i in range(3)], + {"op": "add_conductor", "folio": "$f", "from": "$e0", "from_terminal": 0, "to": "$e1", "to_terminal": 0}, + {"op": "add_conductor", "folio": "$f", "from": "$e0", "from_terminal": 0, "to": "$e2", "to_terminal": 0}, + {"op": "set_conductor", "folio": "$f", "element": "$e1", "terminal": 0, "property": "num", "value": "W7"}, + {"op": "set_conductor", "folio": "$f", "element": "$e1", "terminal": 0, "property": "style", "value": "dashed"}] + r = self.ok(self.sb.edit(base, hub)) + rows = m.tool_conductors(r["output"])["conductors"] + self.assertEqual([c["num"] for c in rows], ["W7", "W7"]) + + # ---- conductor manual routing ---- + + def test_move_conductor_segment_reroutes_the_path(self): + base = self.sb.new() + ops = [{"op": "add_folio", "id": "f"}, + {"op": "add_element", "id": "a", "folio": "$f", "path": COIL, "x": 100, "y": 100}, + {"op": "add_element", "id": "b", "folio": "$f", "path": COIL, "x": 400, "y": 300}, + {"op": "add_conductor", "folio": "$f", "from": "$a", "from_terminal": 0, + "to": "$b", "to_terminal": 0}, + # the vertical segment between the two horizontal legs of the + # default L-route -- see test_two_terminals_default_route_is_l_shaped + # for how this index was established + {"op": "move_conductor_segment", "folio": "$f", "element": "$a", + "terminal": 0, "segment": 2, "dx": 50, "dy": 0}] + r = self.ok(self.sb.edit(base, ops)) + self.assertTrue(r["operations"][-1]["succeeded"]) + xml = Path(r["output"]).read_text(encoding="utf-8") + self.assertIn("b copies; b->c does not, since c was not copied") + self.assertEqual([f["elements"] for f in info["folios"]], [3, 2]) + + def test_duplicate_is_one_undo_step_and_refusals(self): + script = (f"var f=qet.addFolio(),g=qet.addFolio();var a=qet.addElement(f,'{COIL}',0,0),b=qet.addElement(f,'{COIL}',200,0);" + "qet.addConductor(f,a,0,b,0);qet.selectElement(b);var out={};" + "out.n=qet.duplicateElements(f,[a,b],g,50,50).length;" + "out.kept=qet.selectedElements(f).join()===b;" + "qet.undo();out.after_undo=[qet.elementCount(g),qet.conductorCount(g)];" + "out.bad=[qet.duplicateElements(f,['{nope}'],g,0,0).length," + "qet.duplicateElements(f,[a],9,0,0).length,qet.duplicateElements(f,[],g,0,0).length];" + "qet.log('DU '+JSON.stringify(out));") + out = m._run_qet(BINARY, [self.sb.new("dupu")], elements_dir=ELEMENTS, script=script) + line = [l for l in (out["stdout"] + out["stderr"]).splitlines() if l.startswith("DU ")][0] + r = json.loads(line[3:]) + self.assertEqual(r["n"], 2) + self.assertTrue(r["kept"], "copying works by selecting, so the selection must be given back") + self.assertEqual(r["after_undo"], [0, 0], "elements and conductor go in one step") + self.assertEqual(r["bad"], [0, 0, 0]) + + def test_a_failed_duplicate_stops_the_run(self): + base = self.sb.new() + r = self.sb.edit(base, [ + {"op": "add_folio", "id": "f"}, + {"op": "duplicate_elements", "folio": "$f", "elements": ["{not-an-element}"], + "to_folio": "$f", "x": 1, "y": 1}, + {"op": "add_element", "folio": "$f", "path": COIL, "x": 0, "y": 0}]) + self.assertFalse(r["ok"]) + self.assertTrue(r["stopped_early"]) + self.assertEqual(len(r["operations"]), 2) + + # ---- element auto-numbering ---- + + def test_element_numbering_labels_in_sequence_and_undoes_as_one_step(self): + """setUpFormula() writes the label straight into the element and only + the counter's advance is undoable, so for an element already placed + one undo rolled the counter back and left the label: c3 stayed 'K3' + while the counter went back to expecting K3.""" + script = (f"var f=qet.addFolio();qet.addAutoNum('element','EL',['elementprefix','unit:1:1']);" + "qet.useElementAutoNum('EL');var e=[];" + f"for(var i=0;i<3;i++)e.push(qet.addElement(f,'{COIL}',i*200,0));" + "e.forEach(function(u){qet.numberElement(f,u)});" + "var out={after:e.map(function(u){return qet.elementLabel(f,u)})};" + "qet.undo();out.undone=e.map(function(u){return qet.elementLabel(f,u)});" + "qet.numberElement(f,e[2]);out.again=qet.elementLabel(f,e[2]);" + "qet.log('EN '+JSON.stringify(out));") + out = m._run_qet(BINARY, [self.sb.new("num")], elements_dir=ELEMENTS, script=script) + line = [l for l in (out["stdout"] + out["stderr"]).splitlines() if l.startswith("EN ")][0] + r = json.loads(line[3:]) + self.assertEqual(r["after"], ["K1", "K2", "K3"]) + self.assertEqual(r["undone"], ["K1", "K2", ""], "the label must go back with the counter") + self.assertEqual(r["again"], "K3", "the counter must agree with the labels") + + def test_element_numbering_refusals(self): + base = self.sb.new() + r = self.sb.edit(base, [ + {"op": "add_folio", "id": "f"}, + {"op": "add_element", "id": "a", "folio": "$f", "path": COIL, "x": 0, "y": 0}, + {"op": "number_element", "folio": "$f", "element": "$a"}]) + self.assertFalse(r["ok"], "no context selected: nothing to apply") + r = self.sb.edit(base, [ + {"op": "add_folio", "id": "f"}, + {"op": "add_autonum", "kind": "element", "name": "EL", "parts": ["elementprefix", "unit:1:1"]}, + {"op": "use_element_autonum", "name": "EL"}, + {"op": "add_element", "id": "s", "folio": "$f", "path": SLAVE, "x": 0, "y": 0}, + {"op": "number_element", "folio": "$f", "element": "$s"}]) + self.assertFalse(r["ok"], "a slave takes its label from its master") + + # ---- element text fields ---- + + def test_element_text_fields_follow_the_info_and_can_be_restyled(self): + base = self.sb.new() + first = self.ok(self.sb.edit(base, [ + {"op": "add_element", "id": "k", "folio": 0, "path": COIL, "x": 100, "y": 100}, + {"op": "set_label", "folio": 0, "element": "$k", "label": "KM1"}])) + uuid = first["operations"][0]["result"] + r = self.ok(self.sb.edit(first["output"], [ + {"op": "set_element_text", "folio": 0, "element": uuid, "index": 2, "property": "x", "value": "60"}, + {"op": "set_element_text", "folio": 0, "element": uuid, "index": 2, "property": "size", "value": "14"}, + {"op": "set_element_text", "folio": 0, "element": uuid, "index": 2, "property": "frame", "value": "true"}, + {"op": "add_element_text", "id": "c", "folio": 0, "element": uuid, "source": "info", + "value": "comment", "x": 40, "y": 30}, + {"op": "set_info", "folio": 0, "element": uuid, "key": "comment", "value": "24VDC coil"}], "restyled.qet")) + d = r["diff"]["element_texts"] + self.assertEqual(len(d["added"]), 1) + self.assertEqual(d["changed"][0]["changed"], + {"x": ["30", "60"], "size": ["9", "14"], "frame": ["false", "true"]}) + # the added field is bound to the comment key, so it shows the value set afterwards + xml = Path(r["output"]).read_text(encoding="utf-8") + self.assertRegex(xml, r"24VDC coil\s*comment") + + def test_shows_follows_the_information_and_text_does_not(self): + """Two things are called text on an information-bound field. The + stored "text" property is an unused placeholder ('' or 'Texte'); what + is drawn -- "shows" -- follows the element's information immediately. + An earlier version of this test claimed the display was stale and + that a helper was needed; measuring showed toPlainText() matched the + information at every checkpoint, and it was the stored text that + looked wrong. Reading the stored text as the display is the mistake + this pins.""" + script = (f"var f=qet.addFolio();var e=qet.addElement(f,'{COIL}',0,0);" + "var out=[];function snap(t){var lab=-1,com=-1;" + "qet.elementTexts(f,e).forEach(function(l,i){if(l.indexOf(\"info='label'\")>=0)lab=i;" + "if(l.indexOf(\"info='comment'\")>=0)com=i;});" + "out.push({t:t,shows:qet.elementTextProperty(f,e,lab,'shows'),info:qet.elementInfo(f,e,'label')," + "stored:qet.elementTextProperty(f,e,lab,'text')," + "cshows:com>=0?qet.elementTextProperty(f,e,com,'shows'):null," + "cinfo:com>=0?qet.elementInfo(f,e,'comment'):null});}" + "snap('fresh');qet.setElementLabel(f,e,'KM1');snap('label');" + "qet.addElementText(f,e,'info','comment',1,1);qet.setElementInfo(f,e,'comment','hello');snap('comment');" + "qet.setElementLabel(f,e,'KM9');snap('relabel');" + "qet.log('TX '+JSON.stringify(out));") + out = m._run_qet(BINARY, [self.sb.new("live2")], elements_dir=ELEMENTS, script=script) + line = [l for l in (out["stdout"] + out["stderr"]).splitlines() if l.startswith("TX ")][0] + rows = json.loads(line[3:]) + for r in rows: + with self.subTest(step=r["t"]): + self.assertEqual(r["shows"], r["info"]) + if r["cinfo"] is not None: + self.assertEqual(r["cshows"], r["cinfo"]) + self.assertEqual([r["shows"] for r in rows], ["", "KM1", "KM1", "KM9"]) + # the stored property is not the display: it never became KM1/KM9 + self.assertEqual({r["stored"] for r in rows}, {""}) + + def test_bad_element_text_arguments_are_refused(self): + base = self.sb.new() + r = self.sb.edit(base, [ + {"op": "add_element", "id": "k", "folio": 0, "path": COIL, "x": 0, "y": 0}, + {"op": "add_element_text", "folio": 0, "element": "$k", "source": "info", "value": "nonsense", + "x": 0, "y": 0}]) + self.assertFalse(r["ok"], "'nonsense' is not an element information key") + + # ---- text, shapes, images ---- + + def test_text_shape_and_image_round_trip_through_diff(self): + img = self.sb.dir / "pic.png" + png(img) + base = self.sb.new() + r = self.ok(self.sb.edit(base, [ + {"op": "add_text", "id": "t", "folio": 0, "text": "note", "x": 50, "y": 50}, + {"op": "set_text_color", "folio": 0, "index": "$t", "color": "#ff0000"}, + {"op": "add_shape", "id": "s", "folio": 0, "shape": "rectangle", "x1": 10, "y1": 10, "x2": 200, "y2": 120}, + {"op": "set_shape", "folio": 0, "index": "$s", "property": "fill", "value": "#00ff00"}, + {"op": "add_image", "id": "i", "folio": 0, "file": str(img), "x": 60, "y": 200}, + {"op": "scale_image", "folio": 0, "index": "$i", "factor": 2}])) + d = r["diff"] + self.assertEqual([t["text"] for t in d["texts"]["added"]], ["note"]) + self.assertEqual(len(d["shapes"]["added"]), 1) + self.assertEqual(len(d["images"]["added"]), 1) + + # ---- polygon and path shapes ---- + + def test_add_polygon_with_more_than_two_points(self): + base = self.sb.new() + r = self.ok(self.sb.edit(base, [ + {"op": "add_polygon", "folio": 0, + "points": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, + {"x": 0, "y": 100}, {"x": 50, "y": 150}], + "closed": True}])) + self.assertEqual(len(r["diff"]["shapes"]["added"]), 1) + xml = Path(r["output"]).read_text(encoding="utf-8") + self.assertEqual(xml.count("]*>([^<]+)", xml) + self.assertEqual(len(images), 2) + self.assertNotEqual(images[0], images[1], + "page 1 and page 2 must render different content") + + def test_add_pdf_page_dpi_controls_pixel_size(self): + doc = self.sb.dir / "one_page.pdf" + pdf(doc, page_colors=("1 0 0",)) + base = self.sb.new() + r72 = self.ok(self.sb.edit(base, [ + {"op": "add_pdf_page", "folio": 0, "file": str(doc), "page": 1, "dpi": 72, + "x": 0, "y": 0}], out="dpi72.qet")) + r144 = self.ok(self.sb.edit(base, [ + {"op": "add_pdf_page", "folio": 0, "file": str(doc), "page": 1, "dpi": 144, + "x": 0, "y": 0}], out="dpi144.qet")) + def png_width(qet_path): + import base64 + import struct + xml = Path(qet_path).read_text(encoding="utf-8") + b64 = re.search(r"]*>([^<]+)", xml).group(1) + png_bytes = base64.b64decode(b64) + return struct.unpack(">I", png_bytes[16:20])[0] # IHDR width + + w72 = png_width(r72["output"]) + w144 = png_width(r144["output"]) + self.assertAlmostEqual(w144 / w72, 2.0, places=1) + + def test_add_pdf_page_bad_page_number_is_refused(self): + doc = self.sb.dir / "two_pages.pdf" + pdf(doc) + base = self.sb.new() + r = self.sb.edit(base, [ + {"op": "add_pdf_page", "folio": 0, "file": str(doc), "page": 99, "dpi": 150, + "x": 0, "y": 0}]) + self.assertFalse(r["ok"]) + + def test_add_pdf_page_bad_file_is_refused(self): + base = self.sb.new() + r = self.sb.edit(base, [ + {"op": "add_pdf_page", "folio": 0, "file": str(self.sb.dir / "missing.pdf"), + "page": 1, "dpi": 150, "x": 0, "y": 0}]) + self.assertFalse(r["ok"]) + + not_a_pdf = self.sb.dir / "notpdf.pdf" + not_a_pdf.write_bytes(b"not a pdf") + r = self.sb.edit(base, [ + {"op": "add_pdf_page", "folio": 0, "file": str(not_a_pdf), "page": 1, + "dpi": 150, "x": 0, "y": 0}]) + self.assertFalse(r["ok"]) + + def test_image_is_embedded_not_referenced(self): + img = self.sb.dir / "pic.png" + png(img) + base = self.sb.new() + r = self.ok(self.sb.edit(base, [ + {"op": "add_image", "folio": 0, "file": str(img), "x": 0, "y": 0}])) + img.unlink() # the source is gone + again = m.tool_query(BINARY, r["output"], "SELECT COUNT(*) AS n FROM diagram") + self.assertTrue(again["ok"], "the project must open without the source file") + self.assertIn("= 2 and i["link_type"] in ("simple", "master", "slave", "terminal") + and all(i["terminal_names"]) and len(set(i["terminal_names"])) == i["terminals"] + and not i["terminal_order_ambiguous"]] + + def file_order(i): + return [t.get("name") or "" for t in ET.parse(i["file"]).getroot().iter("terminal")] + differ = [i for i in pool if file_order(i) != i["terminal_names"]] + self.assertGreater(len(differ), 50, "the property under test must actually vary") + pick = differ[:40] + [i for i in pool if file_order(i) == i["terminal_names"]][:10] + js = "var f=qet.addFolio();var out=[];\n" + "".join( + f"var u=qet.addElement(f,{json.dumps(i['path'])},{100 + (n % 10) * 80},{100 + (n // 10) * 80});" + f"out.push({{p:{json.dumps(i['path'])},t:u?qet.elementTerminals(f,u):[]}});\n" + for n, i in enumerate(pick)) + "qet.log('ROWS '+JSON.stringify(out));\n" + out = m._run_qet(BINARY, [self.sb.new("order")], elements_dir=ELEMENTS, script=js, tail=2_000_000) + rows = json.loads([l for l in (out["stdout"] + out["stderr"]).splitlines() if l.startswith("ROWS ")][0][5:]) + by = {r["p"]: r["t"] for r in rows} + wrong = [] + for i in pick: + actual = [re.match(r"\d+: (.*) \(\d+ conductor", t).group(1) for t in by[i["path"]]] + if actual != i["terminal_names"]: + wrong.append((i["path"], i["terminal_names"], actual)) + self.assertEqual(wrong, [], f"{len(wrong)} of {len(pick)} disagree") + + # ---- capability probe ---- + + def test_every_method_the_server_needs_exists_in_the_binary(self): + r = m.tool_edit(BINARY, self.sb.new(), [{"op": "add_folio"}], self.sb.p("p.qet"), + elements_dir=ELEMENTS) + self.assertEqual(r["missing_methods"], []) + + def test_a_binary_without_scripting_is_reported_not_run(self): + """Point the tool at a script-less 'binary' (any executable that is + not QElectroTech) and it must refuse rather than hang.""" + fake = self.sb.dir / "fake" + fake.write_text("#!/bin/sh\nexit 0\n") + fake.chmod(0o755) + r = m.tool_edit(str(fake), self.sb.new(), [{"op": "add_folio"}], self.sb.p("x.qet"), timeout=15) + self.assertFalse(r["ok"]) + + +@needs_binary +class PlcIntegration(unittest.TestCase): + """PLC IO table and PLC-slave linking, against the fixtures in + fixtures/ rather than the real QET_ELEMENTS collection (see + PLC_FIXTURES).""" + + def setUp(self): + self.sb = Sandbox() + + def tearDown(self): + self.sb.close() + + def ok(self, r): + self.assertTrue(r["ok"], json.dumps({k: r.get(k) for k in ("hint", "operations")}, default=str)[:600]) + return r + + def edit(self, project, ops, out="out.qet"): + return m.tool_edit(BINARY, project, ops, self.sb.p(out), elements_dir=PLC_FIXTURES) + + def test_add_set_and_remove_plc_io(self): + base = self.sb.new() + ops = [{"op": "add_folio", "id": "f"}, + {"op": "add_element", "id": "m", "folio": "$f", "path": PLC_MASTER, "x": 100, "y": 100}, + {"op": "add_plc_io", "id": "io0", "folio": "$f", "element": "$m", + "type": "entree_digitale", "address": "1.0", "function": "Start", "comment": "c"}, + {"op": "add_plc_io", "id": "io1", "folio": "$f", "element": "$m", + "type": "sortie_digitale", "address": "1.1", "function": "Motor K1", "comment": ""}, + {"op": "set_plc_io", "folio": "$f", "element": "$m", "index": "$io0", + "property": "address", "value": "1.2"}, + {"op": "remove_plc_io", "folio": "$f", "element": "$m", "index": "$io1"}] + r = self.ok(self.edit(base, ops)) + self.assertEqual([o["result"] for o in r["operations"][2:4]], [0, 1]) + self.assertTrue(all(o["succeeded"] for o in r["operations"][4:])) + + def test_add_plc_io_on_non_master_is_refused(self): + base = self.sb.new() + r = self.edit(base, [ + {"op": "add_folio", "id": "f"}, + {"op": "add_element", "id": "s", "folio": "$f", "path": PLC_SLAVE, "x": 100, "y": 100}, + {"op": "add_plc_io", "folio": "$f", "element": "$s", + "type": "entree_digitale", "address": "1.0", "function": "f", "comment": "c"}]) + self.assertFalse(r["ok"]) + + def test_link_plc_io_targets_a_specific_row(self): + base = self.sb.new() + ops = [{"op": "add_folio", "id": "f"}, + {"op": "add_element", "id": "m", "folio": "$f", "path": PLC_MASTER, "x": 100, "y": 100}, + {"op": "add_element", "id": "s", "folio": "$f", "path": PLC_SLAVE, "x": 300, "y": 100}, + {"op": "add_plc_io", "folio": "$f", "element": "$m", + "type": "entree_digitale", "address": "1.0", "function": "Start", "comment": ""}, + {"op": "add_plc_io", "folio": "$f", "element": "$m", + "type": "sortie_digitale", "address": "1.1", "function": "Motor K1", "comment": ""}, + {"op": "link_plc_io", "folio": "$f", "element": "$m", + "to_folio": "$f", "to": "$s", "io_index": 1}] + r = self.ok(self.edit(base, ops)) + self.assertTrue(r["operations"][-1]["succeeded"]) + xml = Path(r["output"]).read_text(encoding="utf-8") + self.assertIn("