From 372e541a0025329e19637c9191e351c1ab284f53 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 3 Oct 2026 13:37:00 +1300 Subject: [PATCH] qet-mcp: ops that line symbols up so wires come out straight A wire is straight only when its two terminals are exactly in line, and qet_edit placed a symbol only by its origin, so an assistant had to guess where the terminals would land -- and usually missed by a few pixels. - place_element adds a symbol with one of its terminals in line with another symbol's, gap pixels away on the side that terminal faces, and notes when the new symbol's terminal faces the wrong way to meet it. - align_terminal moves a placed symbol across so two terminals line up. - align_elements lines up symbols' boxes (left/center/right/top/middle/ bottom), as Edit > Align does. - distribute_elements spaces symbols' origins evenly, or a pitch apart, keeping symbols on the grid on it. place_element and align_terminal need qet.terminalPosition(); on a build without it the edit stops before running, naming the missing call, and every other op still works there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015FPuYPS4T7QuEwjNu22rXD --- misc/qet-mcp/README.md | 23 +++++ misc/qet-mcp/qet_mcp.py | 184 ++++++++++++++++++++++++++++++++++- misc/qet-mcp/test_qet_mcp.py | 155 +++++++++++++++++++++++++++++ 3 files changed, 361 insertions(+), 1 deletion(-) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index fae8c0e59..8a73c51ff 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -377,6 +377,29 @@ open but never shows the token. Four elements moved by one uniform delta; nothing was relabelled. That is the answer a screenshot gave wrongly. +**Draw with straight wires from the start** + +A wire is straight only when its two terminals are exactly in line, and a +symbol is placed by its origin, not by its terminals. `place_element` does +the arithmetic: it adds a symbol with one of its terminals in line with +another symbol's, `gap` pixels away (40 by default), on the side that +terminal faces. + +```json +{"op": "add_element", "folio": 0, "path": "common://…/borne_2.elmt", "x": 100, "y": 100, "id": "x1"}, +{"op": "place_element", "folio": 0, "path": "common://…/contact.elmt", + "terminal": 0, "next_to": "$x1", "next_to_terminal": 2, "id": "k1"}, +{"op": "add_conductor", "folio": 0, "from": "$x1", "from_terminal": 2, "to": "$k1", "to_terminal": 0} +``` + +For a column of current paths (IEC) the next symbol goes below; for a +ladder rung (NFPA) to the right, with the symbols rotated so their +terminals face along the rung. If the named terminal faces the wrong way, +the op says so in its `note`. `align_terminal` lines up a symbol that is +already placed; `align_elements` and `distribute_elements` line up and +space whole rows or columns. `place_element` and `align_terminal` need a +QElectroTech with `qet.terminalPosition()`. + **Draw something, and check it landed** ```json diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index bb4f8ea05..f3c672f98 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -1523,6 +1523,40 @@ OPS = { "set_table_position": ("setTablePosition", [("folio", "folio"), ("table", "table"), ("x", "num"), ("y", "num")]), "delete_table": ("deleteTable", [("folio", "folio"), ("table", "table")]), + # Lining symbols up. These run several calls each, in helpers the + # generated script defines (qetMcp*), not one scripting call. + "align_elements": ("qetMcpAlign", [("folio", "folio"), ("elements", "elmts"), + ("edge", "str"), ("to", "elmt")]), + "distribute_elements": ("qetMcpDistribute", [("folio", "folio"), ("elements", "elmts"), + ("axis", "str"), ("pitch", "num")]), + # Place a symbol so one of its terminals is exactly in line with + # another symbol's: the wire between the two is then straight. Returns + # the new element's uuid, as add_element does. + "place_element": ("qetMcpPlace", [("folio", "folio"), ("path", "str"), + ("terminal", "anyterm"), ("next_to", "elmt"), + ("next_to_terminal", "anyterm"), + ("side", "str"), ("gap", "num")]), + "align_terminal": ("qetMcpAlignTerminal", [("folio", "folio"), ("element", "elmt"), + ("terminal", "anyterm"), ("to", "elmt"), + ("to_terminal", "anyterm")]), +} + +# Arguments those ops may leave out. +OP_DEFAULTS = { + "align_elements": {"to": ""}, + "distribute_elements": {"pitch": 0}, + "place_element": {"side": "", "gap": 40}, +} +ALIGN_EDGES = ["left", "center", "right", "top", "middle", "bottom"] +DISTRIBUTE_AXES = ["horizontal", "vertical"] +PLACE_SIDES = ["", "below", "above", "right", "left"] +# What the helpers call, beyond what every edit needs. +_HELPER_NEEDS = { + "qetMcpAlign": {"elementGeometry", "moveElement"}, + "qetMcpDistribute": {"elementGeometry", "moveElement"}, + "qetMcpPlace": {"elementGeometry", "addElement", "deleteElement", "moveElement", + "terminalPosition", "terminalIndex"}, + "qetMcpAlignTerminal": {"moveElement", "terminalPosition", "terminalIndex"}, } SHAPES = ["line", "rectangle", "ellipse", "polygon"] @@ -1578,7 +1612,8 @@ CONDUCTOR_DEFAULT_PROPERTIES = ["onetextperfolio"] + CONDUCTOR_PROPERTIES # 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} - _ROUTE_METHODS) | +_REQUIRED_METHODS = sorted(({m for m, _ in OPS.values() + if m and not m.startswith("qetMcp")} - _ROUTE_METHODS) | {"save", "folioCount", "conductorCount", "elementCount"}) _MARKER = "QETEDIT " @@ -1660,6 +1695,95 @@ def _build_script(operations: list, output: str) -> str: "' (or two of its terminals carry it)'}));", " return t;", "}", + # The layout ops' helpers. qetMcpOp is the index of the op running, + # for the notes they log. + "var qetMcpOp = -1;", + "function qetMcpNote(note) {", + f" qet.log({_js(_MARKER)} + JSON.stringify(" + "{kind: 'op_note', index: qetMcpOp, note: note}));", + "}", + "function qetMcpTerm(folio, element, t) {", + " return typeof t === 'string' ? qet.terminalIndex(folio, element, t) : t;", + "}", + "function qetMcpAlign(folio, els, edge, to) {", + " var ref = qet.elementGeometry(folio, to || els[0]);", + " if (ref.left === undefined) { qetMcpNote('no element ' + (to || els[0])); return false; }", + " var across = edge === 'left' || edge === 'center' || edge === 'right';", + " function at(g) {", + " if (edge === 'center') return (g.left + g.right) / 2;", + " if (edge === 'middle') return (g.top + g.bottom) / 2;", + " return g[edge];", + " }", + " var moved = 0;", + " for (var i = 0; i < els.length; i++) {", + " if (els[i] === to) continue;", + " var g = qet.elementGeometry(folio, els[i]);", + " if (g.left === undefined) { qetMcpNote('no element ' + els[i]); return false; }", + " var d = at(ref) - at(g);", + " if (Math.abs(d) < 1e-9) continue;", + " if (!qet.moveElement(folio, els[i], across ? d : 0, across ? 0 : d)) return false;", + " moved++;", + " }", + " return moved;", + "}", + "function qetMcpDistribute(folio, els, axis, pitch) {", + " var k = axis === 'horizontal' ? 'x' : 'y', gs = [];", + " for (var i = 0; i < els.length; i++) {", + " var g = qet.elementGeometry(folio, els[i]);", + " if (g.left === undefined) { qetMcpNote('no element ' + els[i]); return false; }", + " gs.push({e: els[i], v: g[k]});", + " }", + " gs.sort(function (a, b) { return a.v - b.v; });", + " var n = gs.length, first = gs[0].v;", + " var step = pitch > 0 ? pitch : (gs[n - 1].v - first) / (n - 1);", + " var moved = 0;", + " for (var j = 1; j < n; j++) {", + " var t = first + j * step;", + # an even split of a gap that is not a whole number of grid steps + # rounds each place to the grid, so symbols on it stay on it + " if (pitch <= 0 && j < n - 1) t = first + Math.round((t - first) / 10) * 10;", + " var d = t - gs[j].v;", + " if (Math.abs(d) < 1e-9) continue;", + " if (!qet.moveElement(folio, gs[j].e, k === 'x' ? d : 0, k === 'y' ? d : 0)) return false;", + " moved++;", + " }", + " return moved;", + "}", + "var qetMcpStep = {below: [0, 1], above: [0, -1], right: [1, 0], left: [-1, 0]};", + "var qetMcpFacingSide = {s: 'below', n: 'above', e: 'right', w: 'left'};", + "var qetMcpBack = {below: 'n', above: 's', right: 'w', left: 'e'};", + "function qetMcpPlace(folio, path, term, near, nearTerm, side, gap) {", + " var g = qet.elementGeometry(folio, near);", + " if (g.left === undefined) { qetMcpNote('no element ' + near); return ''; }", + " var to = qet.terminalPosition(folio, near, qetMcpTerm(folio, near, nearTerm));", + " if (to.x === undefined) { qetMcpNote('next_to_terminal ' + nearTerm + ' is not a terminal of ' + near); return ''; }", + " var el = qet.addElement(folio, path, g.x, g.y);", + " if (!el) return '';", + " var mine = qet.terminalPosition(folio, el, qetMcpTerm(folio, el, term));", + " if (mine.x === undefined) {", + " qet.deleteElement(folio, el);", + " qetMcpNote('terminal ' + term + ' is not a terminal of ' + path);", + " return '';", + " }", + " side = side || qetMcpFacingSide[to.facing];", + " var s = qetMcpStep[side];", + " if (!qet.moveElement(folio, el, to.x + s[0] * gap - mine.x, to.y + s[1] * gap - mine.y)) return '';", + " if (mine.facing !== qetMcpBack[side])", + " qetMcpNote('terminal ' + term + ' of the new symbol faces ' + mine.facing + ', not '", + " + qetMcpBack[side] + ': the wire to it will bend. Rotate the symbol (rotate_element) '", + " + 'or name another terminal');", + " return el;", + "}", + "function qetMcpAlignTerminal(folio, el, term, to, toTerm) {", + " var a = qet.terminalPosition(folio, to, qetMcpTerm(folio, to, toTerm));", + " var b = qet.terminalPosition(folio, el, qetMcpTerm(folio, el, term));", + " if (a.x === undefined || b.x === undefined) {", + " qetMcpNote('terminal not found: ' + (a.x === undefined ? to + ' ' + toTerm : el + ' ' + term));", + " return false;", + " }", + " var along = a.facing === 'n' || a.facing === 's';", + " return qet.moveElement(folio, el, along ? a.x - b.x : 0, along ? 0 : a.y - b.y);", + "}", "if (missing.length === 0) {", ] @@ -1731,6 +1855,16 @@ def _build_script(operations: list, output: str) -> str: 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 == "anyterm": + # A terminal of an element the helper resolves itself (for + # place_element, one that does not exist yet): its index, or its + # uuid, which the helper turns into the index. + if isinstance(value, str) and _UUID_RE.fullmatch(value): + return _js(value) + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + 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. @@ -1868,6 +2002,36 @@ def _build_script(operations: list, output: str) -> str: # The conductor ops take "conductor": "{uuid}" in place of element + # terminal: a uuid names one conductor for good, where a terminal # can carry several. + if name in OP_DEFAULTS: + op = {**OP_DEFAULTS[name], **op} + if name == "align_elements": + if op.get("edge") not in ALIGN_EDGES: + raise ValueError(f"operation {i}: unknown edge {op.get('edge')!r}; " + f"expected one of {', '.join(ALIGN_EDGES)}") + if not isinstance(op.get("elements"), list) or len(op["elements"]) < 2: + raise ValueError(f"operation {i}: align_elements needs at least 2 elements") + if name == "distribute_elements": + if op.get("axis") not in DISTRIBUTE_AXES: + raise ValueError(f"operation {i}: unknown axis {op.get('axis')!r}; " + f"expected one of {', '.join(DISTRIBUTE_AXES)}") + pitch = op.get("pitch") + if isinstance(pitch, (int, float)) and not isinstance(pitch, bool) and pitch < 0: + raise ValueError(f"operation {i}: pitch must be >= 0 (0: spread evenly)") + least = 2 if isinstance(pitch, (int, float)) and pitch > 0 else 3 + if not isinstance(op.get("elements"), list) or len(op["elements"]) < least: + raise ValueError(f"operation {i}: distribute_elements needs at least " + f"{least} elements" + ("" if least == 2 else + " (or 2 with a \"pitch\")")) + if name == "place_element": + if op.get("side") not in PLACE_SIDES: + raise ValueError(f"operation {i}: unknown side {op.get('side')!r}; expected " + f"one of {', '.join(s for s in PLACE_SIDES if s)}, or none " + "for the way next_to_terminal faces") + gap = op.get("gap") + if isinstance(gap, (int, float)) and not isinstance(gap, bool) and gap <= 0: + raise ValueError(f"operation {i}: gap must be > 0") + if method and method.startswith("qetMcp"): + uuid_methods.update(_HELPER_NEEDS[method]) conductor_js = None if name in CONDUCTOR_UUID_OPS and "conductor" in op: if "element" in op or "terminal" in op: @@ -1904,7 +2068,10 @@ def _build_script(operations: list, output: str) -> str: raise ValueError(f"operation {i}: \"id\" {ident!r} is already used") call = "qet.addFolio()" if method is None else f"qet.{method}({', '.join(args)})" + if method and method.startswith("qetMcp"): + call = f"{method}({', '.join(args)})" lines.append(" if (!stop) {") + lines.append(f" qetMcpOp = {i};") if conductor_js is not None: lines.append(f" var e{i} = qetMcpConductorEnd({i}, {args[0]}, {conductor_js});") call = f"(e{i} ? {call} : false)" @@ -3603,6 +3770,21 @@ TOOLS = [ "description": "Operations applied in order. Each is an object with \"op\" " "and that op's arguments. Ops: " + ", ".join(sorted(OPS)) + ". " + "Lining symbols up, so wires come out straight: a wire is " + "straight only when its two terminals are exactly in line. " + "place_element (folio, path, terminal, next_to, " + "next_to_terminal, optional side below|above|right|left -- " + "default the way next_to_terminal faces -- and gap, 40 px " + "terminal to terminal) adds a symbol with its terminal in line " + "with next_to's, and notes when that terminal faces the wrong " + "way; align_terminal (folio, element, terminal, to, " + "to_terminal) moves a placed symbol across so the two are in " + "line; align_elements (folio, elements, edge left|center|right|" + "top|middle|bottom, optional to) lines up their boxes, as Edit > " + "Align does; distribute_elements (folio, elements, axis " + "horizontal|vertical, optional pitch) spaces their origins " + "evenly, or pitch apart. place_element and align_terminal need " + "a build with qet.terminalPosition(). " "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 " diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 45cd968bc..8019d85dd 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -474,6 +474,16 @@ class EditValidation(unittest.TestCase): "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}], + "align_elements": two + [{"op": "align_elements", "folio": "$f", + "elements": ["$e", "$e2"], "edge": "middle"}], + "distribute_elements": two + [{"op": "distribute_elements", "folio": "$f", + "elements": ["$e", "$e2"], "axis": "vertical", + "pitch": 80}], + "place_element": el + [{"op": "place_element", "folio": "$f", "path": "common://x.elmt", + "terminal": 0, "next_to": "$e", "next_to_terminal": 1, + "side": "below", "gap": 60}], + "align_terminal": two + [{"op": "align_terminal", "folio": "$f", "element": "$e2", + "terminal": 0, "to": "$e", "to_terminal": 1}], }) self.assertEqual(set(samples), set(m.OPS), "an op has no sample here: add one so it is exercised") @@ -1303,6 +1313,65 @@ class CheckAndContinuityAnswers(unittest.TestCase): self.assertEqual(m.tool_continuity("qet", str(self.qet), folio=0)["finding_count"], 0) +class LayoutOpsValidation(unittest.TestCase): + """align_elements, distribute_elements, place_element, align_terminal: + their arguments are checked before QElectroTech starts, and the script + asks for terminalPosition() only when an op needs it.""" + + A, B, C = ("{00000000-0000-0000-0000-00000000000a}", "{00000000-0000-0000-0000-00000000000b}", + "{00000000-0000-0000-0000-00000000000c}") + + def bad(self, op, needle): + with self.assertRaises(ValueError) as cm: + m._build_script([op], "/tmp/x.qet") + self.assertIn(needle, str(cm.exception)) + + def test_align_arguments(self): + self.bad({"op": "align_elements", "folio": 0, "elements": [self.A, self.B], + "edge": "diagonal"}, "unknown edge") + self.bad({"op": "align_elements", "folio": 0, "elements": [self.A], "edge": "left"}, + "at least 2") + + def test_distribute_arguments(self): + self.bad({"op": "distribute_elements", "folio": 0, "elements": [self.A, self.B, self.C], + "axis": "diagonal"}, "unknown axis") + self.bad({"op": "distribute_elements", "folio": 0, "elements": [self.A, self.B], + "axis": "horizontal"}, "at least 3") + self.bad({"op": "distribute_elements", "folio": 0, "elements": [self.A, self.B, self.C], + "axis": "vertical", "pitch": -10}, "pitch must be >= 0") + m._build_script([{"op": "distribute_elements", "folio": 0, "elements": [self.A, self.B], + "axis": "vertical", "pitch": 80}], "/tmp/x.qet") + + def test_place_arguments(self): + base = {"op": "place_element", "folio": 0, "path": "common://x.elmt", "terminal": 0, + "next_to": self.A, "next_to_terminal": 1} + self.bad({**base, "side": "up"}, "unknown side") + self.bad({**base, "gap": 0}, "gap must be > 0") + self.bad({**base, "terminal": True}, "terminal index or its uuid") + self.bad({**base, "terminal": -1}, "terminal index or its uuid") + self.bad({**base, "terminal": "A1"}, "terminal index or its uuid") + s = m._build_script([{**base, "terminal": "{11111111-2222-3333-4444-555555555555}"}], + "/tmp/x.qet") + self.assertIn('qetMcpPlace(0, "common://x.elmt", "{11111111-2222-3333-4444-555555555555}", ' + f'"{self.A}", 1, "", 40)', s) + + def test_helpers_are_called_bare_and_needs_follow_use(self): + place = m._build_script([{"op": "align_terminal", "folio": 0, "element": self.A, + "terminal": 0, "to": self.B, "to_terminal": 1}], "/tmp/x.qet") + self.assertIn("qetMcpAlignTerminal(0, ", place) + self.assertNotIn("qet.qetMcp", place) + self.assertIn('"terminalPosition"', place) + plain = m._build_script([{"op": "move_element", "folio": 0, "element": self.A, + "dx": 1, "dy": 0}], "/tmp/x.qet") + self.assertNotIn("terminalPosition\"", plain.split("var need = ")[1].split(";")[0]) + self.assertNotIn("qetMcpAlign\"", plain) + + def test_defaults_fill_in(self): + s = m._build_script([{"op": "align_elements", "folio": 0, "elements": [self.A, self.B], + "edge": "center"}], "/tmp/x.qet") + self.assertIn(f'qetMcpAlign(0, ["{self.A}", "{self.B}"], "center", "")', s) + + class ElementSearch(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() @@ -5268,6 +5337,92 @@ class Integration(unittest.TestCase): self.assertFalse(r["ok"]) +@needs_elements +class LayoutOpsIntegration(unittest.TestCase): + """The layout ops on a real QElectroTech. place_element and + align_terminal need terminalPosition(); on a build without it they are + skipped here, and the refusal itself is checked instead.""" + + def setUp(self): + self.sb = Sandbox() + + def tearDown(self): + self.sb.close() + + def placed(self, ops, out="out.qet"): + r = self.sb.edit(self.sb.new(), ops, out=out) + return r + + def xy(self, path): + return {e["uuid"]: (float(e["x"]), float(e["y"])) for e in m.tool_elements(path)["elements"]} + + def add(self, i, x, y, path=COIL): + return {"op": "add_element", "folio": 0, "path": path, "x": x, "y": y, "id": f"e{i}"} + + def test_align_center(self): + r = self.placed([self.add(0, 100, 100), self.add(1, 137, 200), self.add(2, 90, 300), + {"op": "align_elements", "folio": 0, "elements": ["$e0", "$e1", "$e2"], + "edge": "center"}]) + self.assertTrue(r["ok"], r.get("hint")) + self.assertEqual({x for x, _ in self.xy(r["output"]).values()}, {100.0}) + + def test_distribute_evenly_and_by_pitch(self): + r = self.placed([self.add(0, 100, 100), self.add(1, 130, 100), self.add(2, 300, 100), + {"op": "distribute_elements", "folio": 0, + "elements": ["$e0", "$e1", "$e2"], "axis": "horizontal"}]) + self.assertTrue(r["ok"], r.get("hint")) + self.assertEqual(sorted(x for x, _ in self.xy(r["output"]).values()), [100, 200, 300]) + r = self.placed([self.add(0, 100, 100), self.add(1, 100, 130), + {"op": "distribute_elements", "folio": 0, "elements": ["$e0", "$e1"], + "axis": "vertical", "pitch": 80}], out="pitch.qet") + self.assertTrue(r["ok"], r.get("hint")) + self.assertEqual(sorted(y for _, y in self.xy(r["output"]).values()), [100, 180]) + + def need_positions(self, r): + if "terminalPosition" in (r.get("missing_methods") or []): + self.assertFalse(r["ok"]) + self.assertIn("terminalPosition", r["hint"]) + self.skipTest("this build has no terminalPosition()") + + def test_place_element_lines_up_the_terminals(self): + # borne_2's bottom terminal (index 2, local 0,10) over the contact's + # top one (index 0, local 0,-20): 40 px apart, dock to dock + r = self.placed([self.add(0, 100, 100, TERMINAL), + {"op": "place_element", "folio": 0, "path": SLAVE, "terminal": 0, + "next_to": "$e0", "next_to_terminal": 2, "id": "k"}, + {"op": "add_conductor", "folio": 0, "from": "$e0", "from_terminal": 2, + "to": "$k", "to_terminal": 0}]) + self.need_positions(r) + self.assertTrue(r["ok"], r.get("hint")) + placed = r["operations"][1]["result"] + self.assertEqual(self.xy(r["output"])[placed], (100.0, 100 + 10 + 40 + 20)) + self.assertNotIn("note", r["operations"][1]) + + def test_place_element_warns_about_a_terminal_facing_away(self): + r = self.placed([self.add(0, 100, 100, TERMINAL), + {"op": "place_element", "folio": 0, "path": SLAVE, "terminal": 0, + "next_to": "$e0", "next_to_terminal": 1}]) # side terminal + self.need_positions(r) + self.assertTrue(r["ok"], r.get("hint")) + self.assertIn("faces n, not w", r["operations"][1]["note"]) + + def test_place_element_with_no_such_terminal_places_nothing(self): + r = self.placed([self.add(0, 100, 100, TERMINAL), + {"op": "place_element", "folio": 0, "path": SLAVE, "terminal": 9, + "next_to": "$e0", "next_to_terminal": 2}]) + self.need_positions(r) + self.assertFalse(r["ok"]) + self.assertEqual(len(self.xy(r["output"])), 1) + + def test_align_terminal_moves_across_only(self): + r = self.placed([self.add(0, 100, 100, TERMINAL), self.add(1, 127, 200, SLAVE), + {"op": "align_terminal", "folio": 0, "element": "$e1", "terminal": 0, + "to": "$e0", "to_terminal": 2}]) + self.need_positions(r) + self.assertTrue(r["ok"], r.get("hint")) + self.assertIn((100.0, 200.0), self.xy(r["output"]).values()) + + @needs_binary class PlcIntegration(unittest.TestCase): """PLC IO table and PLC-slave linking, against the fixtures in