From f58bf7502ece45fecdb7dda8f96f6982cbc82e1f Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 10:54:03 +1300 Subject: [PATCH 1/2] 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.""" From ccc063a19eac80e8fe2ba16e76337a99470452fb Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 10:56:45 +1300 Subject: [PATCH 2/2] qet-mcp: report every wrong terminal uuid of an op, each by its argument Review of the previous commit: an add_conductor with both terminal uuids wrong logged two notes under one op index and the second replaced the first, and neither said which end it was about. Notes of one op are now joined, and a terminal note starts with its argument ("from_terminal:", "to_terminal:", "terminal:"). The integration test's both-ends case fails with the old replacing behaviour. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/qet_mcp.py | 11 +++++++---- misc/qet-mcp/test_qet_mcp.py | 26 ++++++++++++++++++-------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 35cedb1d5..68d064d53 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -1513,10 +1513,10 @@ def _build_script(operations: list, output: str) -> str: # 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) {", + "function qetMcpTerminal(index, key, 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 + " + "index: index, note: key + ': no terminal ' + uuid + ' on element ' + element + " "' (or two of its terminals carry it)'}));", " return t;", "}", @@ -1580,7 +1580,7 @@ def _build_script(operations: list, output: str) -> str: # 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}, " + return (f"qetMcpTerminal({op_index}, {_js(key)}, {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 " @@ -1808,7 +1808,10 @@ def _parse_script_output(text: str) -> dict: (isinstance(r, int) and not isinstance(r, bool) and r == -1)) ops.append(rec) elif rec.get("kind") == "op_note": - notes[rec.get("index")] = rec.get("note") + # An op can log more than one (add_conductor, one per end): + # keep them all rather than only the last. + idx = rec.get("index") + notes[idx] = (notes[idx] + "; " if idx in notes else "") + str(rec.get("note")) elif rec.get("kind") == "save": saved = bool(rec.get("result")) stopped = bool(rec.get("stopped_early")) diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index c8cda96ff..cd814eb4d 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -251,25 +251,25 @@ class EditValidation(unittest.TestCase): 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(f'qet.addConductor(1, "{E}", qetMcpTerminal(0, "from_terminal", 1, "{E}", "{T}"), ' + f'"{F}", qetMcpTerminal(0, "to_terminal", 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}"), ' + self.assertIn(f'qet.setConductorProperty(0, "{E}", qetMcpTerminal(0, "terminal", 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) + self.assertIn(f'qet.deleteConductor(0, "{E}", qetMcpTerminal(0, "terminal", 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) + self.assertIn(f'qet.moveConductorSegment(0, "{E}", qetMcpTerminal(0, "terminal", 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) + self.assertIn(f'qet.deleteConductor(R["f"], R["a"], qetMcpTerminal(2, "terminal", 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) + self.assertIn(f'qetMcpTerminal(0, "terminal", 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) @@ -2303,7 +2303,17 @@ class Integration(unittest.TestCase): "to": "$a", "to_terminal": a1["uuid"]}]) self.assertFalse(r["ok"]) self.assertTrue(r["stopped_early"]) - self.assertIn("no terminal", json.dumps(r["operations"][1])) + self.assertIn("from_terminal: no terminal", json.dumps(r["operations"][1])) + + # both ends wrong: both reported, each by its argument + 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": "{00000000-0000-4000-8000-000000000002}"}]) + note = r["operations"][1].get("note", "") + self.assertIn("from_terminal: no terminal", note) + self.assertIn("to_terminal: no terminal", note) def test_noop_edit_has_no_conductor_churn(self): """Re-saving renumbers the file's terminal ids; the diff must not