mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-03 17:34:12 +02:00
Merge pull request #1266 from ispyisail/feature/qet-mcp-layout-ops
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m57s
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m57s
Add qet_edit operations that place symbols so wires come out straight
This commit is contained in:
@@ -410,6 +410,29 @@ drawing. The check is read-only. On a QElectroTech build without
|
||||
be read; the answer names those in `unread_wires` and leaves them out of the
|
||||
score.
|
||||
|
||||
**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
|
||||
|
||||
+187
-1
@@ -1524,6 +1524,41 @@ 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"),
|
||||
("angle", "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, "angle": 0},
|
||||
}
|
||||
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",
|
||||
"rotateElement", "terminalPosition", "terminalIndex"},
|
||||
"qetMcpAlignTerminal": {"moveElement", "terminalPosition", "terminalIndex"},
|
||||
}
|
||||
|
||||
SHAPES = ["line", "rectangle", "ellipse", "polygon"]
|
||||
@@ -1579,7 +1614,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 "
|
||||
@@ -1661,6 +1697,97 @@ 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, angle) {",
|
||||
" 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 '';",
|
||||
# turned first, so the terminal is measured where it ends up
|
||||
" if (angle && !qet.rotateElement(folio, el, angle)) { qet.deleteElement(folio, 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) {",
|
||||
]
|
||||
|
||||
@@ -1732,6 +1859,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.
|
||||
@@ -1869,6 +2006,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:
|
||||
@@ -1905,7 +2072,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)"
|
||||
@@ -4213,6 +4383,22 @@ 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 -- gap, 40 px "
|
||||
"terminal to terminal, and angle, a rotation applied first, as "
|
||||
"for a ladder rung) 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 "
|
||||
|
||||
@@ -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")
|
||||
@@ -1528,6 +1538,65 @@ class LayoutCheckTool(unittest.TestCase):
|
||||
self.assertEqual(r["hint"], "boom")
|
||||
|
||||
|
||||
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, 0)', 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()
|
||||
@@ -5560,6 +5629,106 @@ class LayoutIntegration(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_turned_for_a_rung(self):
|
||||
# NFPA: turned 270, the block's terminal 2 (its bottom one) faces
|
||||
# east; the contact placed to its right, turned the same way, then
|
||||
# meets it with terminal 0 (its top one), facing west
|
||||
r = self.placed([self.add(0, 100, 100, TERMINAL),
|
||||
{"op": "rotate_element", "folio": 0, "element": "$e0", "angle": 270},
|
||||
{"op": "place_element", "folio": 0, "path": SLAVE, "terminal": 0,
|
||||
"next_to": "$e0", "next_to_terminal": 2, "angle": 270, "id": "k"}])
|
||||
self.need_positions(r)
|
||||
self.assertTrue(r["ok"], r.get("hint"))
|
||||
self.assertNotIn("note", r["operations"][2], r["operations"][2])
|
||||
x, y = self.xy(r["output"])[r["operations"][2]["result"]]
|
||||
self.assertEqual(y, 100.0)
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user