From f58bf7502ece45fecdb7dda8f96f6982cbc82e1f Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 10:54:03 +1300 Subject: [PATCH] qet-mcp: qet_edit names a terminal by its uuid "terminal", "from_terminal" and "to_terminal" take the terminal's uuid (as qet_element_info lists it) in place of its index. It names a terminal of the op's own element -- for add_conductor, of that end's element, "$name" references included -- and is turned at run time into the index the call takes by qet.terminalIndex(); if the element has no such terminal the op fails with a note. Unlike the index, a sort by position, it is defined between two terminals at the same point. The lookup is required only when a uuid is used, so index-only edits still run on older builds. README: the terminal uuids; wires of older projects now get a lasting uuid (#1107) instead of staying unnamed. Tests: script generation for every terminal-taking op, "$name" and folio uuid resolution, the index still passed through; through the binary, bobine_ka_a_remanence (file order A2, A1; index order A1, A2) wired A2 to A1 by uuid lands on exactly those ends, and an unknown uuid stops the run with its note. 255/255 against a build with qet.terminalIndex(); a build without it is reported as missing it. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/README.md | 16 +++++++-- misc/qet-mcp/qet_mcp.py | 45 +++++++++++++++++++---- misc/qet-mcp/test_qet_mcp.py | 70 ++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 10 deletions(-) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index c8e994d9b..c09f84bd4 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -305,9 +305,19 @@ Python, plus the hang guard on `addConductor` and the database refresh in `qet.conductorEnds()` in the build. A uuid names one wire, but `set_conductor` still changes the whole potential, the same as by terminal. A project saved before conductors carried a uuid has none in - the file: QElectroTech makes a new one on every load and does not save - it, so `qet_conductors` reports `uuid` as empty and those conductors are - named by `element` + `terminal` until wire uuids last (#1103). + the file until it is saved once: since #1107 QElectroTech works one out + from the wire's two ends on load and writes it on the next save, so it + appears after a first `qet_edit`. +- **A terminal can be named by its uuid**: `terminal`, `from_terminal` and + `to_terminal` take the terminal's uuid (as `qet_element_info` lists it) + in place of its index, on the op's own element (for `add_conductor`, on + that end's element). It is turned at run time into the index the call + takes; if the element has no terminal with it the op fails and its + `note` says so. Unlike the index, which is a sort by position, it is + defined between two terminals at the same point. Needs + `qet.terminalIndex()` in the build. A symbol file saved without terminal + uuids lists them empty; QElectroTech gives the terminals of every + project's copy of it a uuid on opening (#1118), written on the next save. - **`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* diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 09d84c384..35cedb1d5 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -1292,14 +1292,14 @@ OPS = { "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")]), + ("from", "elmt"), ("from_terminal", "term"), + ("to", "elmt"), ("to_terminal", "term")]), "delete_element": ("deleteElement", [("folio", "folio"), ("element", "elmt")]), "set_conductor": ("setConductorProperty", [("folio", "folio"), ("element", "elmt"), - ("terminal", "num"), ("property", "str"), + ("terminal", "term"), ("property", "str"), ("value", "str")]), "move_conductor_segment": ("moveConductorSegment", [("folio", "folio"), ("element", "elmt"), - ("terminal", "num"), ("segment", "num"), + ("terminal", "term"), ("segment", "num"), ("dx", "num"), ("dy", "num")]), "link_elements": ("linkElements", [("folio", "folio"), ("element", "elmt"), ("to_folio", "folio"), ("to", "elmt")]), @@ -1320,7 +1320,7 @@ OPS = { # uuid, which does not shift when another one is added. So # it can be named as "$id". Indexes shift when one is added or deleted. "delete_conductor": ("deleteConductor", [("folio", "folio"), ("element", "elmt"), - ("terminal", "num")]), + ("terminal", "term")]), "remove_folio": ("removeFolio", [("folio", "folio")]), "set_folio": ("setFolioProperty", [("folio", "folio"), ("property", "str"), ("value", "str")]), @@ -1469,6 +1469,7 @@ def _build_script(operations: list, output: str) -> str: uuid_methods: set[str] = set() folio_js = "0" element_js = None # the op's element, for lookups scoped to it + owner_js = None # the element a terminal argument belongs to lines = [ "// generated by qet-mcp; do not edit", "var R = {};", # $name -> value from an earlier op @@ -1509,6 +1510,16 @@ def _build_script(operations: list, output: str) -> str: "{kind: 'op_note', index: index, note: why}));", " return null;", "}", + # A terminal named by uuid is turned into the index the calls take, + # on its own element; -1, with the reason logged, if that element + # has no terminal with it. + "function qetMcpTerminal(index, folio, element, uuid) {", + " var t = qet.terminalIndex(folio, element, uuid);", + " if (t < 0) qet.log(" + _js(_MARKER) + " + JSON.stringify({kind: 'op_note', " + "index: index, note: 'no terminal ' + uuid + ' on element ' + element + " + "' (or two of its terminals carry it)'}));", + " return t;", + "}", "if (missing.length === 0) {", ] @@ -1561,6 +1572,20 @@ def _build_script(operations: list, output: str) -> str: raise ValueError(f"operation {op_index}: {key!r} must be a table index " f"or its uuid, got {value!r}") return _js(value) + if kind == "term": + # A terminal's uuid comes from its symbol's definition, so it + # names a terminal only on its element: the op's element, or for + # add_conductor the end's own. Turned into the index the call + # takes at run time; unlike the index, it is defined between two + # terminals at the same point. + if isinstance(value, str) and _UUID_RE.fullmatch(value): + uuid_methods.add("terminalIndex") + return (f"qetMcpTerminal({op_index}, {folio_js}, {owner_js}, " + f"{_js(value)})") + if not isinstance(value, int) or isinstance(value, bool): + raise ValueError(f"operation {op_index}: {key!r} must be a terminal index " + f"or its uuid, got {value!r}") + return _js(value) if kind == "element_text": # A field's uuid is unique only within its element (copies keep # them), so the lookup takes the op's element too. @@ -1700,6 +1725,7 @@ def _build_script(operations: list, output: str) -> str: raise ValueError(f"operation {i} ({name}) is missing {key!r}") folio_js = args[0] if args else "0" element_js = args[1] if len(args) > 1 else None + owner_js = args[-1] if args else None # a terminal's element precedes it args.append(ref_or(op[key], kind, i, key)) if conductor_js is not None: args[1], args[2] = f"e{i}.element", f"e{i}.terminal" @@ -2635,8 +2661,13 @@ TOOLS = [ "qet_project_info call 1 is \"folio\": 0 here. A folio can " "be given as its uuid instead (qet_project_info lists them), " "which still names the same folio after an earlier op adds or " - "removes one. Terminals are numbered by their " - "index in the element definition; qet_element_info lists them. " + "removes one. A terminal (\"terminal\", \"from_terminal\", " + "\"to_terminal\") is given by its index -- qet_element_info " + "lists terminals in index order, top to bottom then left to " + "right -- or by its uuid, which qet_element_info also lists and " + "which, unlike the index, tells apart two terminals at the same " + "point; a terminal uuid names a terminal of the op's own " + "element (for add_conductor, of that end's element). " "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, " diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index fdac11f55..c8cda96ff 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -242,6 +242,43 @@ class EditValidation(unittest.TestCase): with self.assertRaisesRegex(ValueError, "saved before conductors carried a uuid"): self.build([{"op": "delete_conductor", "folio": 0, "conductor": ""}]) + def test_terminal_by_uuid(self): + """A terminal named by uuid is turned into its index at run time, on + the element it belongs to: the op's element, or each add_conductor + end's own. The lookup is required only then; a plain index is + passed through as before.""" + E, F = "{11111111-2222-4333-8444-555555555555}", "{21111111-2222-4333-8444-555555555555}" + T, V = "{31111111-2222-4333-8444-555555555555}", "{41111111-2222-4333-8444-555555555555}" + s = self.build([{"op": "add_conductor", "folio": 1, "from": E, "from_terminal": T, + "to": F, "to_terminal": V}]) + self.assertIn(f'qet.addConductor(1, "{E}", qetMcpTerminal(0, 1, "{E}", "{T}"), ' + f'"{F}", qetMcpTerminal(0, 1, "{F}", "{V}"))', s) + self.assertIn('"terminalIndex"', s) + s = self.build([{"op": "set_conductor", "folio": 0, "element": E, "terminal": T, + "property": "num", "value": "W1"}]) + self.assertIn(f'qet.setConductorProperty(0, "{E}", qetMcpTerminal(0, 0, "{E}", "{T}"), ' + '"num", "W1")', s) + s = self.build([{"op": "delete_conductor", "folio": 0, "element": E, "terminal": T}]) + self.assertIn(f'qet.deleteConductor(0, "{E}", qetMcpTerminal(0, 0, "{E}", "{T}"))', s) + s = self.build([{"op": "move_conductor_segment", "folio": 0, "element": E, + "terminal": T, "segment": 1, "dx": 5, "dy": 0}]) + self.assertIn(f'qet.moveConductorSegment(0, "{E}", qetMcpTerminal(0, 0, "{E}", "{T}"), 1, 5, 0)', s) + # a $name element and a folio uuid reach the lookup resolved + s = self.build([{"op": "add_folio", "id": "f"}, + {"op": "add_element", "id": "a", "folio": "$f", "path": "x.elmt", "x": 0, "y": 0}, + {"op": "delete_conductor", "folio": "$f", "element": "$a", "terminal": T}]) + self.assertIn(f'qet.deleteConductor(R["f"], R["a"], qetMcpTerminal(2, R["f"], R["a"], "{T}"))', s) + s = self.build([{"op": "delete_conductor", "folio": V, "element": E, "terminal": T}]) + self.assertIn(f'qetMcpTerminal(0, qet.folioIndex("{V}"), "{E}", "{T}")', s) + # an index is unchanged and needs no lookup + s = self.build([{"op": "delete_conductor", "folio": 0, "element": E, "terminal": 2}]) + self.assertIn(f'qet.deleteConductor(0, "{E}", 2)', s) + self.assertNotIn('"terminalIndex"', s) + with self.assertRaisesRegex(ValueError, "terminal index or its uuid"): + self.build([{"op": "delete_conductor", "folio": 0, "element": E, "terminal": "A1"}]) + with self.assertRaisesRegex(ValueError, "terminal index or its uuid"): + self.build([{"op": "delete_conductor", "folio": 0, "element": E, "terminal": True}]) + def test_every_op_generates_a_script(self): # one minimal valid instance of every op f = {"op": "add_folio", "id": "f"} @@ -2235,6 +2272,39 @@ class Integration(unittest.TestCase): nums = [c["num"] for c in m.tool_conductors(r["output"])["conductors"]] self.assertEqual(nums, ["W1"]) + def test_add_conductor_by_terminal_uuid(self): + """The coil's file lists A2 before A1, and its index order puts A1 + first: wiring by uuid reaches the terminals named, whatever their + index, and an unknown uuid stops the run with a note.""" + info = m.tool_element_info(str(Path(ELEMENTS) / COIL.removeprefix("common://"))) + by_name = {t["name"]: t for t in info["terminals"]} + a1, a2 = by_name["A1"], by_name["A2"] + self.assertTrue(a1["uuid"] and a2["uuid"]) + self.assertEqual((a1["index"], a2["index"]), (0, 1)) + base = self.sb.new() + r = self.ok(self.sb.edit(base, [ + {"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": a2["uuid"], + "to": "$b", "to_terminal": a1["uuid"]}])) + self.assertTrue(all(o["succeeded"] for o in r["operations"])) + wires = [c for c in ET.parse(r["output"]).getroot().iter("conductor") + if c.get("terminal1") and c.get("element1")] + self.assertEqual(len(wires), 1) + ends = {(wires[0].get("element1"), wires[0].get("terminal1")), + (wires[0].get("element2"), wires[0].get("terminal2"))} + a_uuid, b_uuid = (o["result"] for o in r["operations"][:2]) + self.assertEqual(ends, {(a_uuid, a2["uuid"]), (b_uuid, a1["uuid"])}) + + r = self.sb.edit(base, [ + {"op": "add_element", "id": "a", "folio": 0, "path": COIL, "x": 100, "y": 100}, + {"op": "add_conductor", "folio": 0, "from": "$a", + "from_terminal": "{00000000-0000-4000-8000-000000000001}", + "to": "$a", "to_terminal": a1["uuid"]}]) + self.assertFalse(r["ok"]) + self.assertTrue(r["stopped_early"]) + self.assertIn("no terminal", json.dumps(r["operations"][1])) + 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."""