diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index e5136fb88..486b85379 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -290,6 +290,18 @@ Python, plus the hang guard on `addConductor` and the database refresh in `index` also takes that uuid, which does not shift the way an index does. `qet_element_build` gives every part of a symbol a uuid as well, returned in `part_uuids`; `qet_element_info` lists them in `part_list`. +- **A conductor can be named by its uuid** (`qet_conductors` reports it): + `set_conductor`, `move_conductor_segment` and `delete_conductor` take + `"conductor": "{uuid}"` in place of `element` + `terminal`, which works + where two conductors meet at a terminal. It is turned at run time into an + end whose terminal carries only that conductor; where both of its ends + are shared the op fails and its `note` says why. Needs + `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). - **`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 7703c4c0e..402cb8be1 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -1354,6 +1354,10 @@ SEARCH_REPLACE_KINDS = ["element_info", "conductor", "text"] FOLIO_PROPERTIES = ["title", "author", "filename", "plant", "locmach", "indexrev", "folio", "template"] +# The ops that address one conductor by element + terminal, and so also +# take "conductor": "{uuid}" (qet_conductors reports each one's uuid). +CONDUCTOR_UUID_OPS = ("set_conductor", "move_conductor_segment", "delete_conductor") + # 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", @@ -1405,6 +1409,35 @@ def _build_script(operations: list, output: str) -> str: f"qet.log({_js(_MARKER)} + JSON.stringify(" "{kind: 'capabilities', missing: missing}));", "var stop = false;", + # A conductor named by uuid is turned into one of its ends, as the + # conductor calls take it: an end whose terminal carries no other + # conductor, so the call cannot pick the wrong one. null, with the + # reason logged, if it is not on the folio or both ends are shared. + "function qetMcpConductorEnd(index, folio, uuid) {", + " var ends = qet.conductorEnds(folio, uuid);", + " var why = 'no conductor ' + uuid + ' on folio ' + folio;", + " if (ends && ends.length === 2) {", + " var lines = qet.conductors(folio);", + " for (var k = 0; k < 2; k++) {", + " if (ends[k] === '?') continue;", + " var n = 0;", + " for (var j = 0; j < lines.length; j++) {", + " var p = lines[j].split(' : ')[0].split(' -- ');", + " if (p[0] === ends[k] || p[1] === ends[k]) n++;", + " }", + " if (n === 1) {", + " var m = ends[k].split(' terminal ');", + " return {element: m[0], terminal: parseInt(m[1], 10)};", + " }", + " }", + " why = 'conductor ' + uuid + ' shares both of its terminals with other '", + " + 'conductors; the conductor calls address one by a terminal carrying '", + " + 'only it';", + " }", + f" qet.log({_js(_MARKER)} + JSON.stringify(" + "{kind: 'op_note', index: index, note: why}));", + " return null;", + "}", "if (missing.length === 0) {", ] @@ -1561,6 +1594,25 @@ def _build_script(operations: list, output: str) -> str: 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)}") + # The conductor ops take "conductor": "{uuid}" in place of element + + # terminal: a uuid names one conductor for good, where a terminal + # can carry several. + conductor_js = None + if name in CONDUCTOR_UUID_OPS and "conductor" in op: + if "element" in op or "terminal" in op: + raise ValueError(f"operation {i} ({name}): give either \"conductor\" " + f"(its uuid) or \"element\" + \"terminal\", not both") + if op["conductor"] == "": + raise ValueError(f"operation {i}: \"conductor\" is empty -- a conductor " + f"from a project saved before conductors carried a uuid " + f"has none in the file; name it by \"element\" + " + f"\"terminal\" instead") + if not isinstance(op["conductor"], str) or not _UUID_RE.fullmatch(op["conductor"]): + raise ValueError(f"operation {i}: \"conductor\" must be a conductor uuid, " + f"got {op['conductor']!r}") + conductor_js = _js(op["conductor"]) + uuid_methods.add("conductorEnds") + op = {**op, "element": "{00000000-0000-0000-0000-000000000000}", "terminal": 0} args = [] for key, kind in spec: if key not in op: @@ -1568,6 +1620,8 @@ def _build_script(operations: list, output: str) -> str: folio_js = args[0] if args else "0" element_js = args[1] if len(args) > 1 else None args.append(ref_or(op[key], kind, i, key)) + if conductor_js is not None: + args[1], args[2] = f"e{i}.element", f"e{i}.terminal" ident = op.get("id") if ident is not None: @@ -1579,6 +1633,9 @@ def _build_script(operations: list, output: str) -> str: call = "qet.addFolio()" if method is None else f"qet.{method}({', '.join(args)})" lines.append(" if (!stop) {") + if conductor_js is not None: + lines.append(f" var e{i} = qetMcpConductorEnd({i}, {args[0]}, {conductor_js});") + call = f"(e{i} ? {call} : false)" lines.append(f" var v{i} = {call};") if ident is not None: lines.append(f" R[{_js(ident)}] = v{i};") @@ -1624,7 +1681,7 @@ def _parse_script_output(text: str) -> dict: 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 + caps, ops, saved, stopped, notes = None, [], None, False, {} for line in text.splitlines(): idx = line.find(_MARKER) if idx < 0: @@ -1643,9 +1700,14 @@ def _parse_script_output(text: str) -> dict: 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") == "op_note": + notes[rec.get("index")] = rec.get("note") elif rec.get("kind") == "save": saved = bool(rec.get("result")) stopped = bool(rec.get("stopped_early")) + for rec in ops: + if rec.get("index") in notes: + rec["note"] = notes[rec["index"]] return {"missing_methods": caps, "operations": ops, "saved": saved, "stopped_early": stopped} @@ -2491,8 +2553,15 @@ TOOLS = [ "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) + + "potential, so name a terminal carrying exactly one conductor, " + "or give \"conductor\": its uuid from qet_conductors in place " + "of \"element\" + \"terminal\" (set_conductor, " + "move_conductor_segment and delete_conductor all accept it; it " + "needs one of the conductor's two terminals to carry only it, " + "and a conductor qet_conductors lists with an empty uuid has " + "none to give). A uuid names one wire, but set_conductor still " + "changes its whole potential, as it does by terminal. " + "set_conductor's \"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 " diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index d9310e6c2..a7a44d76e 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -194,6 +194,34 @@ class EditValidation(unittest.TestCase): with self.assertRaisesRegex(ValueError, "index or its uuid"): self.build([op]) + def test_conductor_by_uuid(self): + """A conductor named by uuid is turned into one of its ends at run + time; the lookup is required only then, and "conductor" cannot be + given alongside element + terminal.""" + U = "{11111111-2222-4333-8444-555555555555}" + s = self.build([{"op": "delete_conductor", "folio": 1, "conductor": U}]) + self.assertIn(f'var e0 = qetMcpConductorEnd(0, 1, "{U}");', s) + self.assertIn("(e0 ? qet.deleteConductor(1, e0.element, e0.terminal) : false)", s) + self.assertIn('"conductorEnds"', s) + s = self.build([{"op": "set_conductor", "folio": 0, "conductor": U, + "property": "num", "value": "W1"}]) + self.assertIn('qet.setConductorProperty(0, e0.element, e0.terminal, "num", "W1")', s) + s = self.build([{"op": "move_conductor_segment", "folio": 2, "conductor": U, + "segment": 1, "dx": 10, "dy": 0}]) + self.assertIn(f'var e0 = qetMcpConductorEnd(0, 2, "{U}");', s) + self.assertIn("(e0 ? qet.moveConductorSegment(2, e0.element, e0.terminal, 1, 10, 0) : false)", s) + # an end whose terminal or element is missing is never picked + self.assertIn("if (ends[k] === '?') continue;", s) + self.assertNotIn('"conductorEnds"', self.build( + [{"op": "delete_conductor", "folio": 0, "element": U, "terminal": 0}])) + with self.assertRaisesRegex(ValueError, "not both"): + self.build([{"op": "delete_conductor", "folio": 0, "conductor": U, + "element": U, "terminal": 0}]) + with self.assertRaisesRegex(ValueError, "must be a conductor uuid"): + self.build([{"op": "delete_conductor", "folio": 0, "conductor": "W1"}]) + with self.assertRaisesRegex(ValueError, "saved before conductors carried a uuid"): + self.build([{"op": "delete_conductor", "folio": 0, "conductor": ""}]) + def test_every_op_generates_a_script(self): # one minimal valid instance of every op f = {"op": "add_folio", "id": "f"} @@ -2379,6 +2407,40 @@ class Integration(unittest.TestCase): r = self.ok(self.sb.edit(base, ops)) self.assertEqual([c["num"] for c in m.tool_conductors(r["output"])["conductors"]], ["W7"]) + def _hub(self): + """e0's terminal 0 carries two conductors, to e1 and to e2. Returns + the saved project and the two conductors' uuids.""" + base = self.sb.new() + r = self.ok(self.sb.edit(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(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}])) + uuids = [c["uuid"] for c in m.tool_conductors(r["output"])["conductors"]] + self.assertEqual(len(uuids), 2) + self.assertTrue(all(uuids), "new conductors carry a saved uuid") + return r["output"], uuids + + def test_conductor_by_uuid_where_two_meet_at_a_terminal(self): + """The terminal e0/0 carries both conductors, which element + terminal + cannot name; each uuid names one, whichever it is.""" + project, uuids = self._hub() + for gone, kept in ((uuids[0], uuids[1]), (uuids[1], uuids[0])): + with self.subTest(deleted=gone): + r = self.ok(self.sb.edit(project, [ + {"op": "delete_conductor", "folio": 1, "conductor": gone}], out="out2.qet")) + left = [c["uuid"] for c in m.tool_conductors(r["output"])["conductors"]] + self.assertEqual(left, [kept]) + + def test_unknown_conductor_uuid_is_reported(self): + project, _ = self._hub() + r = self.sb.edit(project, [{"op": "delete_conductor", "folio": 1, + "conductor": "{11111111-2222-4333-8444-555555555555}"}], + out="out2.qet") + self.assertFalse(r["ok"]) + self.assertIn("no conductor", r["operations"][0].get("note", "")) + # ---- cross references, strips, folios ---- def test_cross_reference_across_folios(self):