#!/usr/bin/env python3
"""
Regression suite for qet_mcp.py.
Three layers, cheapest first:
unit no QElectroTech needed: validation, script generation, the
terminal-order rule, the diff, the part schema, the protocol
protocol the real stdio transport, one JSON-RPC message per line
integration drives a built QElectroTech; skipped unless one is found
python3 test_qet_mcp.py # unit + protocol
QET_BINARY=/path/to/qelectrotech \\
QET_ELEMENTS=/path/to/qelectrotech/elements \\
QET_EXAMPLES=/path/to/qelectrotech/examples \\
python3 test_qet_mcp.py # everything
Several tests exist because the behaviour they pin was once wrong and
looked right. Those say so in their docstring, so nobody "simplifies" one
away: a check that has never been seen to fail is not evidence.
"""
from __future__ import annotations
import csv
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
import unittest.mock
from unittest import mock
import xml.etree.ElementTree as ET
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import qet_mcp as m # noqa: E402
BINARY = os.environ.get("QET_BINARY", "")
ELEMENTS = os.environ.get("QET_ELEMENTS", "")
# Any executable will do where a test never launches the program; macOS
# keeps true in /usr/bin, not /bin.
TRUE = shutil.which("true") or "/bin/true"
EXAMPLES = os.environ.get("QET_EXAMPLES", "")
have_binary = bool(BINARY) and os.access(BINARY, os.X_OK)
have_elements = bool(ELEMENTS) and Path(ELEMENTS).is_dir()
have_examples = bool(EXAMPLES) and Path(EXAMPLES).is_dir()
needs_binary = unittest.skipUnless(have_binary, "set QET_BINARY to a built qelectrotech")
needs_elements = unittest.skipUnless(have_binary and have_elements,
"set QET_BINARY and QET_ELEMENTS")
needs_examples = unittest.skipUnless(have_binary and have_examples,
"set QET_BINARY and QET_EXAMPLES")
def _pdf_page_size(path) -> tuple:
"""The first page's MediaBox width and height, in points. QPdfWriter
writes page dictionaries uncompressed, so no PDF library is needed."""
box = re.search(rb"/MediaBox\s*\[\s*([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)",
Path(path).read_bytes())
x0, y0, x1, y1 = map(float, box.groups())
return (x1 - x0, y1 - y0)
COIL = "common://10_electric/10_allpole/310_relays_contactors_contacts/01_coils/bobine_ka_a_remanence.elmt"
SLAVE = ("common://10_electric/10_allpole/310_relays_contactors_contacts/"
"02_contacts_cross_referencing/15_protection_contacts/contact_relais_nf_esclave.elmt")
SHARED_UUID = "{11111111-2222-3333-4444-555555555555}"
# The shipped "going/coming arrow" pair -- a folio-jump link, next_report on
# one folio linked to previous_report on the next, one terminal each. Same
# category of element as issue #974's custom "naechste_folie_rechts.elmt" /
# "vorherige_folie_links.elmt".
NEXT_REPORT = "common://10_electric/10_allpole/100_folio_referencing/02going_arrow.elmt"
PREVIOUS_REPORT = "common://10_electric/10_allpole/100_folio_referencing/01coming_arrow.elmt"
TERMINAL = "common://10_electric/10_allpole/130_terminals_terminal_strips/borne_2.elmt"
# No shipped element has masterType/slaveType "plc" -- these two minimal
# fixtures (derived from a real coil/slave pair) exist only so PLC IO tests
# have something to place. ElementsLocation only resolves an absolute .elmt
# path when it happens to sit under QETApp::commonElementsDirN(), so they
# are placed via a dedicated elements_dir pointed at this directory, not the
# real QET_ELEMENTS collection.
PLC_FIXTURES = str(HERE / "fixtures")
PLC_MASTER = "common://plc_master_test.elmt"
PLC_SLAVE = "common://plc_slave_test.elmt"
def fake_qet(bindir: Path, name: str = "qelectrotech") -> Path:
"""An executable file standing in for QElectroTech. The policy checks
which file it is, never runs it."""
bindir.mkdir(parents=True, exist_ok=True)
exe = bindir / name
exe.write_text("#!/bin/sh\nexit 0\n")
exe.chmod(0o755)
return exe
def png(path: Path) -> None:
"""A real 64x32 PNG from the standard library, so no imaging dependency."""
import struct
import zlib
w, h = 64, 32
raw = b"".join(b"\x00" + bytes([255, 0, 0] * w) for _ in range(h))
def chunk(t, d):
c = struct.pack(">I", len(d)) + t + d
return c + struct.pack(">I", zlib.crc32(t + d) & 0xFFFFFFFF)
path.write_bytes(b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
def pdf(path: Path, page_colors=("1 0 0", "0 0 1")) -> None:
"""A minimal, hand-built multi-page PDF -- one filled rectangle per
page, in a different colour each, so a test can tell which page a
render actually came from. No external tool (ghostscript, reportlab)
needed; the xref offsets are computed exactly as each object is
written, since QPdfDocument (pdfium) is not guaranteed to tolerate a
wrong one the way some readers repair-scan for."""
n = len(page_colors)
page_objs = list(range(3, 3 + n)) # 3 .. 2+n
content_objs = list(range(3 + n, 3 + 2 * n)) # 3+n .. 2+2n
objects = {}
objects[1] = b"<< /Type /Catalog /Pages 2 0 R >>"
kids = " ".join(f"{o} 0 R" for o in page_objs)
objects[2] = f"<< /Type /Pages /Kids [{kids}] /Count {n} >>".encode()
for i, (page_obj, content_obj) in enumerate(zip(page_objs, content_objs)):
objects[page_obj] = (
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] "
f"/Contents {content_obj} 0 R /Resources << >> >>").encode()
stream = f"{page_colors[i]} rg 10 10 150 150 re f".encode()
objects[content_obj] = (f"<< /Length {len(stream)} >>\nstream\n").encode() \
+ stream + b"\nendstream"
out = bytearray(b"%PDF-1.4\n")
offsets = {}
for num in sorted(objects):
offsets[num] = len(out)
out += f"{num} 0 obj\n".encode() + objects[num] + b"\nendobj\n"
xref_start = len(out)
total = len(objects) + 1
out += f"xref\n0 {total}\n".encode()
out += b"0000000000 65535 f \n"
for num in sorted(objects):
out += f"{offsets[num]:010d} 00000 n \n".encode()
out += f"trailer\n<< /Size {total} /Root 1 0 R >>\nstartxref\n{xref_start}\n%%EOF".encode()
path.write_bytes(bytes(out))
# ==========================================================================
# unit
# ==========================================================================
class ToolRegistry(unittest.TestCase):
def test_names_are_unique_and_prefixed(self):
names = [t["name"] for t in m.TOOLS]
self.assertEqual(len(names), len(set(names)))
self.assertTrue(all(n.startswith("qet_") for n in names))
def test_every_schema_is_well_formed(self):
for t in m.TOOLS:
with self.subTest(tool=t["name"]):
s = t["inputSchema"]
self.assertEqual(s["type"], "object")
self.assertTrue(set(s.get("required", [])) <= set(s["properties"]),
"a required key is missing from properties")
self.assertTrue(callable(t["handler"]))
self.assertTrue(t["description"].strip())
json.dumps(s) # must be serialisable as-is
def test_expected_tools_exist(self):
self.assertEqual({t["name"] for t in m.TOOLS}, {
"qet_project_info", "qet_elements", "qet_conductors", "qet_diff",
"qet_scan", "qet_element_info", "qet_export", "qet_edit", "qet_query",
"qet_project_new", "qet_element_search", "qet_check", "qet_element_build",
"qet_continuity", "qet_items", "qet_script_api", "qet_script_test",
"qet_script_install", "qet_script_list", "qet_script_read",
"qet_script_remove", "qet_live_status", "qet_live_run_script",
"qet_live_run_stored", "qet_live_command", "qet_live_show_folio",
"qet_live_undo_last", "qet_live_screenshot", "qet_about",
"qet_recording_list", "qet_recording_read", "qet_recording_check",
"qet_recording_remove", "qet_layout_check"})
class EditValidation(unittest.TestCase):
"""Every argument error must be raised before QElectroTech is launched.
tool_edit is called with a binary that does not exist: if validation
were lazy the failure would be 'not an executable', not the ValueError
asserted here.
"""
def build(self, ops):
return m._build_script(ops, "/tmp/out.qet")
def test_tables_and_text_fields_by_uuid(self):
"""A table or a symbol text field named by uuid is looked up at run
time; a field's lookup is scoped to the op's element."""
U = "{11111111-2222-4333-8444-555555555555}"
E = "{aaaaaaaa-0000-4000-8000-000000000001}"
s = self.build([{"op": "set_table_position", "folio": 2, "table": U, "x": 1, "y": 2}])
self.assertIn(f'qet.setTablePosition(2, qet.tableIndex(2, "{U}"), 1, 2)', s)
s = self.build([{"op": "delete_table", "folio": 0, "table": 3}])
self.assertIn("qet.deleteTable(0, 3)", s)
s = self.build([{"op": "set_element_text", "folio": 1, "element": E, "index": U,
"property": "x", "value": "5"}])
self.assertIn(f'qet.setElementTextProperty(1, "{E}", '
f'qet.elementTextIndex(1, "{E}", "{U}"), "x", "5")', s)
# the element may be one placed earlier in the same run
s = self.build([{"op": "add_folio", "id": "f"},
{"op": "add_element", "id": "k", "folio": "$f", "path": "p", "x": 0, "y": 0},
{"op": "delete_element_text", "folio": "$f", "element": "$k", "index": U}])
f = '(F["f"] ? qet.folioIndex(F["f"]) : R["f"])' # a folio "$name": see test_folio_ref_follows_the_folio
self.assertIn(f'qet.deleteElementText({f}, R["k"], qet.elementTextIndex({f}, R["k"], "{U}"))', s)
# the lookups are required only when a uuid is used
self.assertIn('"tableIndex"', self.build([{"op": "delete_table", "folio": 0, "table": U}]))
self.assertNotIn('"tableIndex"', self.build([{"op": "delete_table", "folio": 0, "table": 0}]))
for op in ({"op": "delete_table", "folio": 0, "table": "second"},
{"op": "delete_element_text", "folio": 0, "element": E, "index": "label"}):
with self.subTest(op=op["op"]):
with self.assertRaisesRegex(ValueError, "index or its uuid"):
self.build([op])
def test_folio_by_uuid(self):
"""A folio named by uuid is looked up at run time, wherever an op
takes a folio; the lookup is required only then."""
U = "{11111111-2222-4333-8444-555555555555}"
E = "{aaaaaaaa-0000-4000-8000-000000000001}"
s = self.build([{"op": "set_folio", "folio": U, "property": "author", "value": "a"}])
self.assertIn(f'qet.setFolioProperty(qet.folioIndex("{U}"), "author", "a")', s)
self.assertIn('"folioIndex"', s)
# the item lookups take the resolved folio too
s = self.build([{"op": "delete_table", "folio": U, "table": E}])
self.assertIn(f'qet.deleteTable(qet.folioIndex("{U}"), qet.tableIndex(qet.folioIndex("{U}"), "{E}"))', s)
s = self.build([{"op": "link_elements", "folio": 0, "element": E, "to_folio": U, "to": E}])
self.assertIn(f'qet.linkElements(0, "{E}", qet.folioIndex("{U}"), "{E}")', s)
self.assertNotIn('"folioIndex"', self.build(
[{"op": "set_folio", "folio": 0, "property": "author", "value": "a"}]))
with self.assertRaisesRegex(ValueError, "saved once"):
self.build([{"op": "set_folio", "folio": "", "property": "author", "value": "a"}])
with self.assertRaisesRegex(ValueError, "folio index, its uuid"):
self.build([{"op": "set_folio", "folio": "first", "property": "author", "value": "a"}])
def test_folio_ref_follows_the_folio(self):
"""A "$name" made by add_folio or insert_folio is looked up by the
folio's uuid when used as a folio, since a later insert or removal
shifts its index; the stored index is the fallback on a build that
cannot report folio uuids. Used as anything else, it is unchanged."""
s = self.build([{"op": "add_folio", "id": "f"},
{"op": "insert_folio", "id": "g", "position": 0},
{"op": "set_folio_title", "folio": "$f", "title": "t"},
{"op": "set_folio_title", "folio": "$g", "title": "u"}])
self.assertIn("F[\"f\"] = (typeof qet.folioUuid === 'function' && v0 >= 0) "
"? qet.folioUuid(v0) : '';", s)
self.assertIn('qet.setFolioTitle((F["f"] ? qet.folioIndex(F["f"]) : R["f"]), "t")', s)
self.assertIn('qet.setFolioTitle((F["g"] ? qet.folioIndex(F["g"]) : R["g"]), "u")', s)
# not required: an edit still runs on a build without folio uuids
self.assertNotIn('"folioUuid"', s)
# a "$name" from any other op is untouched
s = self.build([{"op": "add_text", "id": "t", "folio": 0, "text": "x", "x": 0, "y": 0},
{"op": "delete_text", "folio": 0, "index": "$t"}])
self.assertIn('R["t"]', s)
self.assertNotIn("F[", s.split("if (missing.length === 0) {")[1])
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_route_ops(self):
"""route_conductor and add_conductor's "route": "avoid" call the
router; neither makes a missing route a failure of the run, and the
router methods are required only by an edit that routes."""
E, F = "{11111111-2222-4333-8444-555555555555}", "{21111111-2222-4333-8444-555555555555}"
U = "{31111111-2222-4333-8444-555555555555}"
plain = self.build([{"op": "add_conductor", "folio": 0, "from": E, "from_terminal": 1,
"to": F, "to_terminal": 0}])
self.assertNotIn("routeConductor", plain)
self.assertEqual(plain, self.build([{"op": "add_conductor", "folio": 0, "from": E,
"from_terminal": 1, "to": F, "to_terminal": 0,
"route": "default"}]))
s = self.build([{"op": "add_conductor", "folio": 0, "from": E, "from_terminal": 1,
"to": F, "to_terminal": 0, "route": "avoid"}])
self.assertIn(f'if (v0 === true) {{ var r0 = qet.routeConductorBetween(0, "{E}", 1, "{F}", 0);', s)
self.assertIn('"routeConductorBetween"', s)
self.assertNotIn('"routeConductor",', s)
# the op's own result is still the conductor being added
self.assertIn("result: v0", s)
s = self.build([{"op": "route_conductor", "folio": 2, "conductor": U}])
self.assertIn("(e0 ? qet.routeConductor(2, e0.element, e0.terminal) : false)", s)
self.assertIn("if (v0 === 'no-route')", s)
self.assertIn('"routeConductor"', s)
s = self.build([{"op": "route_conductor", "folio": 0, "element": E, "terminal": 1}])
self.assertIn(f'qet.routeConductor(0, "{E}", 1)', s)
with self.assertRaisesRegex(ValueError, "unknown route"):
self.build([{"op": "add_conductor", "folio": 0, "from": E, "from_terminal": 1,
"to": F, "to_terminal": 0, "route": "around"}])
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, "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, "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, "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, "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}])
f = '(F["f"] ? qet.folioIndex(F["f"]) : R["f"])' # a folio "$name": see test_folio_ref_follows_the_folio
self.assertIn(f'qet.deleteConductor({f}, R["a"], qetMcpTerminal(2, "terminal", {f}, R["a"], "{T}"))', s)
s = self.build([{"op": "delete_conductor", "folio": V, "element": E, "terminal": T}])
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)
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"}
samples = {
"add_folio": [f],
"set_folio_title": [f, {"op": "set_folio_title", "folio": "$f", "title": "t"}],
"add_element": [f, {"op": "add_element", "id": "e", "folio": "$f", "path": "p", "x": 1, "y": 2}],
}
el = [f, {"op": "add_element", "id": "e", "folio": "$f", "path": "p", "x": 1, "y": 2}]
two = el + [{"op": "add_element", "id": "e2", "folio": "$f", "path": "p", "x": 1, "y": 2}]
samples.update({
"set_position": el + [{"op": "set_position", "folio": "$f", "element": "$e", "x": 1, "y": 1}],
"move_element": el + [{"op": "move_element", "folio": "$f", "element": "$e", "dx": 1, "dy": 1}],
"rotate_element": el + [{"op": "rotate_element", "folio": "$f", "element": "$e", "angle": 90}],
"set_label": el + [{"op": "set_label", "folio": "$f", "element": "$e", "label": "K"}],
"set_info": el + [{"op": "set_info", "folio": "$f", "element": "$e", "key": "k", "value": "v"}],
"add_conductor": two + [{"op": "add_conductor", "folio": "$f", "from": "$e", "from_terminal": 0,
"to": "$e2", "to_terminal": 0}],
"delete_element": el + [{"op": "delete_element", "folio": "$f", "element": "$e"}],
"set_conductor": el + [{"op": "set_conductor", "folio": "$f", "element": "$e", "terminal": 0,
"property": "num", "value": "1"}],
"move_conductor_segment": el + [{"op": "move_conductor_segment", "folio": "$f",
"element": "$e", "terminal": 0, "segment": 1,
"dx": 10, "dy": 0}],
"route_conductor": el + [{"op": "route_conductor", "folio": "$f", "element": "$e",
"terminal": 0}],
"delete_conductor": el + [{"op": "delete_conductor", "folio": "$f", "element": "$e", "terminal": 0}],
"link_elements": two + [{"op": "link_elements", "folio": "$f", "element": "$e",
"to_folio": "$f", "to": "$e2"}],
"unlink_element": el + [{"op": "unlink_element", "folio": "$f", "element": "$e"}],
"remove_folio": [f, {"op": "remove_folio", "folio": "$f"}],
"set_folio": [f, {"op": "set_folio", "folio": "$f", "property": "author", "value": "a"}],
"add_text": [f, {"op": "add_text", "id": "t", "folio": "$f", "text": "x", "x": 0, "y": 0}],
"set_text": [f, {"op": "set_text", "folio": "$f", "index": 0, "text": "x"}],
"set_text_color": [f, {"op": "set_text_color", "folio": "$f", "index": 0, "color": "#000"}],
"rotate_text": [f, {"op": "rotate_text", "folio": "$f", "index": 0, "angle": 5}],
"delete_text": [f, {"op": "delete_text", "folio": "$f", "index": 0}],
"add_shape": [f, {"op": "add_shape", "folio": "$f", "shape": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 1}],
"set_shape": [f, {"op": "set_shape", "folio": "$f", "index": 0, "property": "fill", "value": "none"}],
"delete_shape": [f, {"op": "delete_shape", "folio": "$f", "index": 0}],
"add_polygon": [f, {"op": "add_polygon", "folio": "$f",
"points": [{"x": 0, "y": 0}, {"x": 1, "y": 0}, {"x": 1, "y": 1}],
"closed": True}],
"set_shape_polygon": [f, {"op": "add_polygon", "id": "poly", "folio": "$f",
"points": [{"x": 0, "y": 0}, {"x": 1, "y": 0}, {"x": 1, "y": 1}],
"closed": True},
{"op": "set_shape_polygon", "folio": "$f", "index": "$poly",
"points": [{"x": 2, "y": 2}, {"x": 3, "y": 2}, {"x": 3, "y": 3}]}],
"add_path": [f, {"op": "add_path", "folio": "$f",
"nodes": [{"x": 0, "y": 0}, {"x": 1, "y": 0, "kind": "smooth",
"inHandle": {"x": 0.5, "y": 0}, "outHandle": {"x": 1.5, "y": 0}}],
"closed": False}],
"set_shape_path_nodes": [f, {"op": "add_path", "id": "path", "folio": "$f",
"nodes": [{"x": 0, "y": 0}, {"x": 1, "y": 0}], "closed": False},
{"op": "set_shape_path_nodes", "folio": "$f", "index": "$path",
"nodes": [{"x": 5, "y": 5}, {"x": 6, "y": 5}]}],
"set_shape_closed": [f, {"op": "add_polygon", "id": "poly2", "folio": "$f",
"points": [{"x": 0, "y": 0}, {"x": 1, "y": 0}, {"x": 1, "y": 1}],
"closed": True},
{"op": "set_shape_closed", "folio": "$f", "index": "$poly2", "closed": False}],
"add_image": [f, {"op": "add_image", "folio": "$f", "file": "/x.png", "x": 0, "y": 0}],
"scale_image": [f, {"op": "scale_image", "folio": "$f", "index": 0, "factor": 2}],
"rotate_image": [f, {"op": "rotate_image", "folio": "$f", "index": 0, "angle": 5}],
"delete_image": [f, {"op": "delete_image", "folio": "$f", "index": 0}],
"add_pdf_page": [f, {"op": "add_pdf_page", "folio": "$f", "file": "/x.pdf",
"page": 1, "dpi": 150, "x": 0, "y": 0}],
"add_element_text": el + [{"op": "add_element_text", "id": "t", "folio": "$f", "element": "$e",
"source": "info", "value": "comment", "x": 1, "y": 2}],
"set_element_text": el + [{"op": "set_element_text", "folio": "$f", "element": "$e", "index": 0,
"property": "x", "value": "5"}],
"delete_element_text": el + [{"op": "delete_element_text", "folio": "$f", "element": "$e", "index": 0}],
"add_terminal_strip": [{"op": "add_terminal_strip", "installation": "a", "location": "b", "name": "c"}],
"remove_terminal_strip": [{"op": "remove_terminal_strip", "strip": 0}],
"add_to_strip": el + [{"op": "add_to_strip", "strip": 0, "folio": "$f", "element": "$e"}],
"group_terminals": [{"op": "group_terminals", "strip": 0, "indices": [0, 1]}],
"bridge_terminals": [{"op": "bridge_terminals", "strip": 0, "indices": [0, 1]}],
"sort_terminal_strip": [{"op": "sort_terminal_strip", "strip": 0}],
"add_autonum": [{"op": "add_autonum", "kind": "conductor", "name": "W", "parts": ["string:W"]}],
"remove_autonum": [{"op": "remove_autonum", "kind": "conductor", "name": "W"}],
"rename_autonum": [{"op": "rename_autonum", "kind": "element", "name": "EL", "new_name": "EL2"}],
"use_conductor_autonum": [f, {"op": "use_conductor_autonum", "folio": "$f", "name": "W"}],
"use_element_autonum": [{"op": "use_element_autonum", "name": "EL"}],
"insert_folio": [{"op": "insert_folio", "id": "i", "position": 0}],
"element_geometry": el + [{"op": "element_geometry", "folio": "$f", "element": "$e"}],
"undo": [{"op": "undo"}],
"redo": [{"op": "redo"}],
"search_and_replace": [{"op": "search_and_replace", "kind": "text", "field": "",
"pattern": "x", "replacement": "y",
"regex": False, "case_sensitive": True}],
"set_project_title": [{"op": "set_project_title", "title": "T"}],
"set_folio_border": [f, {"op": "set_folio_border", "folio": "$f", "property": "columns", "value": "10"}],
"set_conductor_default": [f, {"op": "set_conductor_default", "folio": "$f",
"property": "onetextperfolio", "value": "true"}],
"embed_title_block_template": [{"op": "embed_title_block_template", "name": "default"}],
"duplicate_elements": el + [{"op": "duplicate_elements", "id": "d", "folio": "$f",
"elements": ["$e"], "to_folio": "$f", "x": 50, "y": 50}],
"number_element": el + [{"op": "number_element", "folio": "$f", "element": "$e"}],
"renumber_element_autonum": [{"op": "renumber_element_autonum", "name": "EL"}],
"free_element_numbers": el + [{"op": "free_element_numbers", "folio": "$f", "element": "$e"}],
"assign_element_number": el + [{"op": "assign_element_number", "folio": "$f",
"element": "$e", "number": 3}],
"assign_element_autonum": el + [{"op": "assign_element_autonum", "name": "EL", "folio": "$f",
"element": "$e", "overwrite": False}],
"add_table": [f, {"op": "add_table", "id": "t", "folio": "$f", "kind": "nomenclature",
"name": "BOM", "query": "SELECT label FROM element_nomenclature_view"}],
"set_table_position": [f, {"op": "add_table", "id": "t", "folio": "$f", "kind": "nomenclature",
"name": "BOM", "query": "SELECT label FROM element_nomenclature_view"},
{"op": "set_table_position", "folio": "$f", "table": "$t", "x": 1, "y": 1}],
"delete_table": [f, {"op": "add_table", "id": "t", "folio": "$f", "kind": "nomenclature",
"name": "BOM", "query": "SELECT label FROM element_nomenclature_view"},
{"op": "delete_table", "folio": "$f", "table": "$t"}],
"link_plc_io": two + [{"op": "link_plc_io", "folio": "$f", "element": "$e",
"to_folio": "$f", "to": "$e2", "io_index": 0}],
"add_plc_io": el + [{"op": "add_plc_io", "folio": "$f", "element": "$e",
"type": "entree_digitale", "address": "1.0",
"function": "f", "comment": "c"}],
"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")
for name, ops in samples.items():
with self.subTest(op=name):
script = self.build(ops)
self.assertIn("qet.save(", script)
self.assertIn(m.OPS[name][0] or "addFolio", script)
def test_unknown_op(self):
with self.assertRaisesRegex(ValueError, "unknown op"):
self.build([{"op": "draw_a_nice_diagram"}])
def test_missing_argument(self):
with self.assertRaisesRegex(ValueError, "missing 'path'"):
self.build([{"op": "add_element", "folio": 0, "x": 1, "y": 2}])
def test_dangling_reference(self):
with self.assertRaisesRegex(ValueError, "no earlier"):
self.build([{"op": "set_label", "folio": "$nope", "element": "$x", "label": "a"}])
def test_id_rules(self):
for bad in ("$f", "", 3):
with self.subTest(id=bad):
with self.assertRaises(ValueError):
self.build([{"op": "add_folio", "id": bad}])
with self.assertRaisesRegex(ValueError, "already used"):
self.build([{"op": "add_folio", "id": "f"}, {"op": "add_folio", "id": "f"}])
def test_enumerated_arguments_are_checked_up_front(self):
cases = [
{"op": "set_conductor", "folio": 0, "element": "x", "terminal": 0, "property": "voltage", "value": "1"},
{"op": "set_folio", "folio": 0, "property": "version", "value": "1"},
{"op": "set_shape", "folio": 0, "index": 0, "property": "shadow", "value": "1"},
{"op": "add_shape", "folio": 0, "shape": "hexagon", "x1": 0, "y1": 0, "x2": 1, "y2": 1},
{"op": "add_autonum", "kind": "wire", "name": "a", "parts": ["string:W"]},
{"op": "set_element_text", "folio": 0, "element": "x", "index": 0, "property": "shadow", "value": "1"},
{"op": "add_element_text", "folio": 0, "element": "x", "source": "magic", "value": "v", "x": 0, "y": 0},
{"op": "set_folio_border", "folio": 0, "property": "thickness", "value": "3"},
]
for c in cases:
with self.subTest(op=c["op"], bad=c.get("property") or c.get("shape") or c.get("kind")):
with self.assertRaises(ValueError):
self.build([c])
def test_folio_preset_is_checked_up_front_and_probed_for(self):
with self.assertRaisesRegex(ValueError, "unknown folio preset 'b4-portrait'"):
self.build([{"op": "set_folio_border", "folio": 0, "property": "preset",
"value": "b4-portrait"}])
script = self.build([{"op": "set_folio_border", "folio": 0, "property": "preset",
"value": "Tabloid-Landscape"}])
self.assertIn('qet.setFolioBorder(0, "preset", "Tabloid-Landscape")', script)
# a build without presets is told so, not left to fail on the call
self.assertIn('"folioPresets"', script)
self.assertIn("frame", script)
plain = self.build([{"op": "set_folio_border", "folio": 0, "property": "columns",
"value": "10"}])
self.assertNotIn('"folioPresets"', plain)
def test_version_is_not_a_settable_folio_property(self):
"""setFolioProperty('version') reported success and was overwritten by
the file-format stamp. It must stay refused on every layer."""
self.assertNotIn("version", m.FOLIO_PROPERTIES)
def test_list_argument_must_be_list_of_strings(self):
with self.assertRaisesRegex(ValueError, "list of strings"):
self.build([{"op": "add_autonum", "kind": "conductor", "name": "a", "parts": "string:W"}])
def test_a_uuid_index_is_resolved_at_run_time(self):
u = "{11111111-1111-4111-8111-111111111111}"
script = self.build([{"op": "set_shape", "folio": 2, "index": u,
"property": "fill", "value": "none"},
{"op": "delete_text", "folio": 1, "index": u},
{"op": "delete_image", "folio": 0, "index": u}])
self.assertIn(f'qet.setShapeProperty(2, qet.shapeIndex(2, "{u}"), ', script)
self.assertIn(f'qet.deleteText(1, qet.textIndex(1, "{u}"))', script)
self.assertIn(f'qet.deleteImage(0, qet.imageIndex(0, "{u}"))', script)
need = json.loads(re.search(r"var need = (\[.*?\]);", script).group(1))
self.assertTrue({"shapeIndex", "textIndex", "imageIndex"} <= set(need))
def test_an_index_only_edit_does_not_need_the_uuid_resolvers(self):
script = self.build([{"op": "delete_shape", "folio": 0, "index": 3}])
need = json.loads(re.search(r"var need = (\[.*?\]);", script).group(1))
self.assertFalse({"shapeIndex", "textIndex", "imageIndex"} & set(need))
def test_malformed_arguments_are_refused(self):
"""Each argument kind rejects what it cannot take, before any launch.
Found by the mutation audit: forcing any of these checks off went
unnoticed."""
f = {"op": "add_folio", "id": "f"}
cases = [
("indices", {"op": "group_terminals", "strip": 0, "indices": []}),
("indices", {"op": "group_terminals", "strip": 0, "indices": [1, "2"]}),
("indices", {"op": "group_terminals", "strip": 0, "indices": [True]}),
("indices", {"op": "bridge_terminals", "strip": 0, "indices": 3}),
("true or false", {"op": "add_polygon", "folio": 0, "closed": 1,
"points": [{"x": 0, "y": 0}, {"x": 1, "y": 1}]}),
("at least 2", {"op": "add_polygon", "folio": 0, "closed": True,
"points": [{"x": 0, "y": 0}]}),
("at least 2", {"op": "add_polygon", "folio": 0, "closed": True, "points": "xy"}),
("entries must be", {"op": "add_polygon", "folio": 0, "closed": True,
"points": [{"x": 0, "y": 0}, {"x": "1", "y": 1}]}),
("entries must be", {"op": "add_polygon", "folio": 0, "closed": True,
"points": [{"x": 0, "y": 0}, {"x": True, "y": 1}]}),
("entries must be", {"op": "add_polygon", "folio": 0, "closed": True,
"points": [{"x": 0, "y": 0}, [1, 1]]}),
("at least 2 nodes", {"op": "add_path", "folio": 0, "closed": False,
"nodes": [{"x": 0, "y": 0}]}),
("corner, smooth or symmetric", {"op": "add_path", "folio": 0, "closed": False,
"nodes": [{"x": 0, "y": 0},
{"x": 1, "y": 1, "kind": "sharp"}]}),
("inHandle", {"op": "add_path", "folio": 0, "closed": False,
"nodes": [{"x": 0, "y": 0}, {"x": 1, "y": 1, "inHandle": [0, 0]}]}),
("outHandle", {"op": "add_path", "folio": 0, "closed": False,
"nodes": [{"x": 0, "y": 0}, {"x": 1, "y": 1, "outHandle": {"x": 0}}]}),
("unknown kind", {"op": "search_and_replace", "kind": "folio", "field": "x",
"pattern": "a", "replacement": "b", "regex": False,
"case_sensitive": False}),
("unknown conductor default", {"op": "set_conductor_default", "folio": 0,
"property": "label", "value": "x"}),
("unknown conductor field", {"op": "search_and_replace", "kind": "conductor",
"field": "label", "pattern": "a", "replacement": "b",
"regex": False, "case_sensitive": False}),
("non-empty", {"op": "search_and_replace", "kind": "element_info", "field": "",
"pattern": "a", "replacement": "b", "regex": False,
"case_sensitive": False}),
("not both", {"op": "delete_conductor", "folio": 0,
"conductor": "{11111111-2222-4333-8444-555555555555}",
"element": "{11111111-2222-4333-8444-555555555555}"}),
("not both", {"op": "delete_conductor", "folio": 0,
"conductor": "{11111111-2222-4333-8444-555555555555}", "terminal": 0}),
("not an object", "add_folio"),
]
for message, op in cases:
with self.subTest(op=op):
with self.assertRaisesRegex(ValueError, message):
self.build([f, op])
def test_valid_shapes_of_those_arguments_pass(self):
"""The other side: the same kinds accept what they should, so the
refusals above are not everything failing."""
s = self.build([
{"op": "add_polygon", "folio": 0, "closed": False,
"points": [{"x": 0, "y": 0}, {"x": 1.5, "y": -2}]},
{"op": "add_path", "folio": 0, "closed": True,
"nodes": [{"x": 0, "y": 0, "kind": "smooth", "inHandle": {"x": 1, "y": 1}},
{"x": 5, "y": 5, "outHandle": {"x": 2, "y": 2}}]},
{"op": "add_polygon", "folio": 0, "closed": True,
"points": [{"x": 0, "y": 0, "kind": "anything"}, {"x": 1, "y": 1}]},
{"op": "search_and_replace", "kind": "conductor", "field": "num", "pattern": "a",
"replacement": "b", "regex": True, "case_sensitive": False},
{"op": "search_and_replace", "kind": "text", "field": "", "pattern": "a",
"replacement": "b", "regex": False, "case_sensitive": True}])
self.assertIn("qet.addPolygon(0, [{", s)
self.assertIn("qet.searchAndReplace(\"conductor\", \"num\", \"a\", \"b\", true, false)", s)
self.assertIn("qet.searchAndReplace(\"text\", \"\", \"a\", \"b\", false, true)", s)
def test_no_id_stores_nothing(self):
s = self.build([{"op": "add_folio"}])
self.assertNotIn("R[", s.split("if (missing.length === 0) {")[1])
def test_a_string_index_that_is_not_a_uuid_is_refused(self):
for bad in ("3", "{nope}", "11111111-1111"):
with self.subTest(index=bad):
with self.assertRaises(ValueError):
self.build([{"op": "delete_shape", "folio": 0, "index": bad}])
def test_indexed_references_and_element_lists(self):
script = self.build([
{"op": "add_folio", "id": "f"},
{"op": "add_element", "id": "a", "folio": "$f", "path": "p", "x": 0, "y": 0},
{"op": "add_element", "id": "b", "folio": "$f", "path": "p", "x": 9, "y": 0},
{"op": "duplicate_elements", "id": "c", "folio": "$f", "elements": ["$a", "$b"],
"to_folio": "$f", "x": 5, "y": 5},
{"op": "set_label", "folio": "$f", "element": "$c[1]", "label": "X"}])
self.assertIn('R["c"][1]', script)
self.assertIn('[R["a"], R["b"]]', script)
for bad in ("$c[", "$c[x]", "$nope[0]"):
with self.subTest(ref=bad):
with self.assertRaises(ValueError):
self.build([{"op": "add_folio", "id": "f"},
{"op": "set_label", "folio": "$f", "element": bad, "label": "X"}])
for bad in ([], "a", [1, 2]):
with self.subTest(elements=bad):
with self.assertRaises(ValueError):
self.build([{"op": "add_folio", "id": "f"},
{"op": "duplicate_elements", "folio": "$f", "elements": bad,
"to_folio": "$f", "x": 0, "y": 0}])
def test_an_empty_object_result_is_a_failure(self):
"""elementGeometry returns {} for an element it cannot find. An empty
object has no .length, so the empty-list test did not cover it."""
out = m._MARKER + json.dumps(dict(kind="op", index=0, op="element_geometry", id=None, result={}))
self.assertFalse(m._parse_script_output(out)["operations"][0]["succeeded"])
out = m._MARKER + json.dumps(dict(kind="op", index=0, op="element_geometry", id=None,
result={"x": 0, "y": 0}))
self.assertTrue(m._parse_script_output(out)["operations"][0]["succeeded"])
script = m._build_script([{"op": "add_folio"}], "/o")
self.assertIn("Object.keys(", script)
def test_an_empty_list_result_is_a_failure(self):
"""duplicateElements returns [] on failure, which the success check
did not recognise: neither falsy in JavaScript's === comparisons nor
equal to False in Python."""
out = m._MARKER + json.dumps(dict(kind="op", index=0, op="duplicate_elements", id="d", result=[]))
self.assertFalse(m._parse_script_output(out)["operations"][0]["succeeded"])
# the JavaScript side must not rely on Array.isArray: QJSEngine returns
# an empty QStringList as an array-like wrapper for which it is false
script = m._build_script([{"op": "add_folio"}], "/o")
self.assertIn(".length === 0", script)
self.assertNotIn("Array.isArray", script)
def test_number_arguments_reject_bool_and_text(self):
for bad in (True, "1", None):
with self.subTest(x=bad):
with self.assertRaises(ValueError):
self.build([{"op": "add_folio", "id": "f"},
{"op": "add_text", "folio": "$f", "text": "t", "x": bad, "y": 0}])
def test_string_values_are_escaped_into_the_script(self):
nasty = 'a"b\\c\n '
script = self.build([{"op": "add_folio", "id": "f"},
{"op": "set_folio_title", "folio": "$f", "title": nasty}])
# the literal must be valid JSON, so JavaScript reads exactly what was sent
literal = re.search(r'setFolioTitle\(\(F\["f"\] \? qet\.folioIndex\(F\["f"\]\) : R\["f"\]\), (".*?")\)',
script, re.S).group(1)
self.assertEqual(json.loads(literal), nasty)
def test_edit_refuses_in_place_and_empty(self):
with tempfile.TemporaryDirectory() as tmp:
p = Path(tmp) / "a.qet"
p.write_text("")
with self.assertRaisesRegex(ValueError, "differ from project"):
m.tool_edit("/nonexistent", str(p), [{"op": "add_folio"}], str(p))
with self.assertRaisesRegex(ValueError, "non-empty"):
m.tool_edit("/nonexistent", str(p), [], str(Path(tmp) / "o.qet"))
class ResultParsing(unittest.TestCase):
def line(self, **kw):
return m._MARKER + json.dumps(kw)
def test_zero_is_a_valid_result(self):
"""0 == False in Python. A valid first index (first text, first folio
of an empty project) was read as failure and marked a successful edit
ok=False."""
out = self.line(kind="op", index=0, op="add_text", id="t", result=0)
rec = m._parse_script_output(out)["operations"][0]
self.assertTrue(rec["succeeded"])
def test_failures_are_recognised(self):
for bad in (False, "", -1, None):
with self.subTest(result=bad):
out = self.line(kind="op", index=0, op="x", id=None, result=bad)
self.assertFalse(m._parse_script_output(out)["operations"][0]["succeeded"])
def test_true_and_strings_succeed(self):
for good in (True, "{uuid}", 1, 7):
with self.subTest(result=good):
out = self.line(kind="op", index=0, op="x", id=None, result=good)
self.assertTrue(m._parse_script_output(out)["operations"][0]["succeeded"])
def test_noise_and_garbage_are_ignored(self):
text = "SQLite version\n" + m._MARKER + "{not json\nplain\n" + \
self.line(kind="save", result=True, stopped_early=False)
parsed = m._parse_script_output(text)
self.assertTrue(parsed["saved"])
self.assertEqual(parsed["operations"], [])
def test_capabilities_notes_and_save(self):
"""Every field the script reports reaches the result, attached where
it belongs. Found by the mutation audit: only the binary tests read
these back."""
text = "\n".join([
self.line(kind="capabilities", missing=["addFolio"]),
self.line(kind="op", index=0, op="a", id=None, result=True),
self.line(kind="op", index=1, op="b", id=None, result=False),
self.line(kind="op_note", index=1, note="why"),
self.line(kind="save", result=True, stopped_early=True),
self.line(kind="something_newer", result=False)]) # not a save
parsed = m._parse_script_output(text)
self.assertEqual(set(parsed), {"missing_methods", "operations", "saved", "stopped_early"})
self.assertEqual(parsed["missing_methods"], ["addFolio"])
self.assertTrue(parsed["saved"])
self.assertTrue(parsed["stopped_early"])
self.assertNotIn("note", parsed["operations"][0])
self.assertEqual(parsed["operations"][1]["note"], "why")
# no capabilities line: unknown, not "nothing missing"
self.assertIsNone(m._parse_script_output(self.line(kind="save", result=False))["missing_methods"])
self.assertEqual(m._parse_script_output(self.line(kind="capabilities", missing=None))
["missing_methods"], [])
def test_a_line_without_the_marker_is_ignored(self):
"""Even one that would parse if read from where the marker would be."""
almost = "x" * (len(m._MARKER) - 1) + json.dumps({"kind": "save", "result": True})
parsed = m._parse_script_output(almost)
self.assertIsNone(parsed["saved"])
self.assertFalse(parsed["stopped_early"])
class TerminalOrder(unittest.TestCase):
"""QElectroTech indexes terminals top-to-bottom then left-to-right, not in
file order. Getting it wrong wires the wrong end of a coil with no error;
619 of the 837 shipped elements with named terminals list them
differently from how they are indexed."""
def nodes(self, *specs):
return [ET.Element("terminal", {"name": n, "x": str(x), "y": str(y)}) for n, x, y in specs]
def test_sorted_by_y_then_x(self):
ordered, amb = m._terminals_in_index_order(
self.nodes(("A2", 0, 20), ("A1", 0, -20)))
self.assertEqual([t.get("name") for t in ordered], ["A1", "A2"])
self.assertFalse(amb)
def test_left_to_right_within_a_row(self):
ordered, _ = m._terminals_in_index_order(
self.nodes(("R", 30, 0), ("L", -30, 0), ("M", 0, 0)))
self.assertEqual([t.get("name") for t in ordered], ["L", "M", "R"])
def test_y_dominates_x(self):
ordered, _ = m._terminals_in_index_order(
self.nodes(("lowleft", -50, 10), ("highright", 50, -10)))
self.assertEqual([t.get("name") for t in ordered], ["highright", "lowleft"])
def test_ties_are_flagged_not_hidden(self):
_, amb = m._terminals_in_index_order(self.nodes(("a", 5, 5), ("b", 5, 5)))
self.assertTrue(amb)
def test_numeric_not_lexical(self):
ordered, _ = m._terminals_in_index_order(
self.nodes(("nine", 0, 9), ("ten", 0, 10), ("minus", 0, -10)))
self.assertEqual([t.get("name") for t in ordered], ["minus", "nine", "ten"])
@unittest.skipUnless(have_elements, "set QET_ELEMENTS")
def test_real_element_disagrees_with_file_order(self):
f = Path(ELEMENTS) / COIL.replace("common://", "")
root = ET.parse(f).getroot()
file_order = [t.get("name") for t in root.iter("terminal")]
ordered, _ = m._terminals_in_index_order(list(root.iter("terminal")))
self.assertEqual(file_order, ["A2", "A1"])
self.assertEqual([t.get("name") for t in ordered], ["A1", "A2"])
class ElementBuild(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.out = str(Path(self.tmp.name) / "e.elmt")
self.ok = dict(names={"en": "x"},
parts=[{"type": "line", "x1": 0, "y1": 0, "x2": 10, "y2": 0}],
terminals=[{"x": 0, "y": 0, "orientation": "n"}])
def tearDown(self):
self.tmp.cleanup()
def build(self, **over):
return m.tool_element_build(self.out, **{**self.ok, **over})
def test_rejections(self):
cases = {
"unknown part": dict(parts=[{"type": "squiggle"}]),
"missing key": dict(parts=[{"type": "rect", "x": 0, "y": 0, "width": 5}]),
"typo'd key": dict(parts=[{"type": "circle", "x": 0, "y": 0, "diametre": 5}]),
"bad orientation": dict(terminals=[{"x": 0, "y": 0, "orientation": "up"}]),
"no names": dict(names={}),
"bad link_type": dict(link_type="widget"),
"connectable, no terminals": dict(terminals=[]),
"one-point polygon": dict(parts=[{"type": "polygon", "points": [[0, 0]]}]),
"text coordinate": dict(parts=[{"type": "line", "x1": "left", "y1": 0, "x2": 1, "y2": 0}]),
}
for label, over in cases.items():
with self.subTest(label):
with self.assertRaises(ValueError):
self.build(**over)
self.assertFalse(Path(self.out).exists(), "nothing may be written on refusal")
def test_an_unexpected_key_on_a_valid_part_is_refused(self):
"""Distinct from a missing key: the part below is complete, and only
the extra attribute is wrong. Without this the 'allowed keys' rule is
never exercised -- the typo case above is refused for lacking
'diameter', which passes even if unexpected keys are accepted."""
with self.assertRaisesRegex(ValueError, "unexpected 'colour'"):
self.build(parts=[{"type": "rect", "x": 0, "y": 0, "width": 5, "height": 5,
"colour": "red"}])
self.assertFalse(Path(self.out).exists())
def test_declared_box_contains_the_drawing(self):
"""The header is a containment constraint, not a formula, and the
assertion behind it is what stops a clipped element."""
for parts in ([{"type": "rect", "x": 100, "y": 100, "width": 50, "height": 50}],
[{"type": "rect", "x": -200, "y": -150, "width": 40, "height": 30}],
[{"type": "circle", "x": -25, "y": -25, "diameter": 50}]):
with self.subTest(parts=parts[0]["type"]):
r = self.build(parts=parts)
x0, y0, x1, y1 = r["bbox"]
self.assertLessEqual(-r["hotspot_x"], x0)
self.assertLessEqual(-r["hotspot_y"], y0)
self.assertGreaterEqual(r["width"] - r["hotspot_x"], x1)
self.assertGreaterEqual(r["height"] - r["hotspot_y"], y1)
def test_reports_terminal_index_order_not_input_order(self):
r = self.build(parts=[{"type": "line", "x1": 0, "y1": -20, "x2": 0, "y2": 20}],
terminals=[{"x": 0, "y": 20, "orientation": "s", "name": "low"},
{"x": 0, "y": -20, "orientation": "n", "name": "high"}])
self.assertEqual(r["terminal_index_order"], ["high", "low"])
def test_every_part_gets_its_own_uuid(self):
parts = [{"type": "line", "x1": 0, "y1": 0, "x2": 10, "y2": 0},
{"type": "rect", "x": 0, "y": 0, "width": 5, "height": 5},
{"type": "text", "x": 0, "y": 0, "text": "K"}]
r = self.build(parts=parts)
desc = ET.parse(self.out).getroot().find("description")
written = [e.get("uuid") for e in desc if e.tag != "terminal"]
self.assertEqual(written, r["part_uuids"])
self.assertEqual(len(set(written)), 3)
self.assertEqual([p["uuid"] for p in r["verified"]["part_list"]], written)
def test_a_given_part_uuid_is_kept_and_braced(self):
r = self.build(parts=[{"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0,
"uuid": "11111111-1111-4111-8111-111111111111"}])
self.assertEqual(r["part_uuids"], ["{11111111-1111-4111-8111-111111111111}"])
def test_bad_or_repeated_part_uuids_are_refused(self):
line = {"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0}
u = "{11111111-1111-4111-8111-111111111111}"
for parts in ([{**line, "uuid": "nope"}], [{**line, "uuid": u}, {**line, "uuid": u}]):
with self.subTest(parts=parts):
with self.assertRaises(ValueError):
self.build(parts=parts)
def test_output_reads_back(self):
r = self.build(names={"en": "Coil", "fr": "Bobine"})
self.assertEqual(r["verified"]["names"], {"en": "Coil", "fr": "Bobine"})
root = ET.parse(self.out).getroot()
self.assertEqual(root.tag, "definition")
self.assertEqual(root.get("link_type"), "simple")
def test_special_characters_survive(self):
r = self.build(names={"en": 'Coil "A" & ', "fr": "Résistance"})
self.assertEqual(r["verified"]["names"]["fr"], "Résistance")
self.assertEqual(r["verified"]["names"]["en"], 'Coil "A" & ')
class ElementFileExact(unittest.TestCase):
"""Exact answers for the tools that write and read .elmt files and need
no QElectroTech. Found by a mutation audit: the existing tests checked a
few fields, so dropping any other one from a result, or getting a
header number off by one, went unnoticed."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
m._ELEMENT_INDEX.clear()
def tearDown(self):
self.tmp.cleanup()
def test_fmt(self):
self.assertEqual([m._fmt(v) for v in (True, False, 3, 2.0, 2.5, -0.25, "x")],
["true", "false", "3", "2", "2.5", "-0.25", "x"])
def test_part_extent_per_kind(self):
ext = m._part_extent
self.assertEqual(ext("line", {"x1": 1, "y1": 2, "x2": 3, "y2": 4}), [(1, 2), (3, 4)])
for kind in ("rect", "ellipse", "arc"):
self.assertEqual(ext(kind, {"x": -5, "y": 1, "width": 10, "height": 4}),
[(-5, 1), (5, 5)], kind)
self.assertEqual(ext("circle", {"x": 2, "y": 3, "diameter": 6}), [(2, 3), (8, 9)])
self.assertEqual(ext("polygon", {"points": [[0, 1], [2, 3], [4, 5]]}),
[(0, 1), (2, 3), (4, 5)])
self.assertEqual(ext("text", {"x": 7, "y": 8, "text": "a"}), [(7, 8)])
self.assertEqual(ext("dynamic_text", {"x": 7}), [])
def test_element_geometry(self):
"""Box, hotspot and size, worked out by hand: points reach x -10..20
and y -15..5; a 5 unit margin, rounded out to tens."""
g = m._element_geometry(
[{"type": "line", "x1": 0, "y1": 0, "x2": 20, "y2": 0},
{"type": "rect", "x": -10, "y": -5, "width": 10, "height": 10}],
[{"x": 0, "y": -15, "orientation": "n"}])
self.assertEqual(g, {"width": 50, "height": 30, "hotspot_x": 20, "hotspot_y": 20,
"bbox": [-10, -15, 20, 5]})
# a terminal alone is enough; nothing at all is refused
self.assertEqual(m._element_geometry([], [{"x": 0, "y": 0, "orientation": "n"}]),
{"width": 20, "height": 20, "hotspot_x": 10, "hotspot_y": 10, "bbox": [0, 0, 0, 0]})
with self.assertRaisesRegex(ValueError, "at least one part or terminal"):
m._element_geometry([], [])
def test_part_element(self):
E = lambda part: dict(m._part_element(part, "{u}").attrib)
self.assertEqual(E({"type": "polygon", "points": [[0, 1], [2.5, 3]]}),
{"uuid": "{u}", "x1": "0", "y1": "1", "x2": "2.5", "y2": "3",
"closed": "true", "antialias": "true", "style": m.DEFAULT_STYLE})
self.assertEqual(E({"type": "polygon", "points": [[0, 1], [2, 3]], "closed": False,
"antialias": False, "style": "x"})["closed"], "false")
self.assertEqual(E({"type": "text", "x": 1, "y": 2, "text": "K1"}),
{"uuid": "{u}", "x": "1", "y": "2", "text": "K1", "rotation": "0",
"font": "Sans Serif,9,-1,5,50,0,0,0,0,0", "color": "#000000"})
self.assertEqual(E({"type": "text", "x": 1, "y": 2, "text": "K1", "size": 12,
"rotation": 90, "color": "red"})["font"],
"Sans Serif,12,-1,5,50,0,0,0,0,0")
rect = E({"type": "rect", "x": 0, "y": 0, "width": 4, "height": 2, "antialias": False})
self.assertEqual(rect, {"uuid": "{u}", "x": "0", "y": "0", "width": "4", "height": "2",
"antialias": "false", "style": m.DEFAULT_STYLE})
def test_build_writes_and_reports_exactly(self):
out = self.root / "k" / "coil.elmt"
U = "{11111111-2222-4333-8444-555555555555}"
r = m.tool_element_build(
str(out), {"fr": "Bobine", "en": "Coil"},
[{"type": "line", "x1": 0, "y1": -10, "x2": 0, "y2": 10}],
terminals=[{"x": 0, "y": 20, "orientation": "s", "name": "A2"},
{"x": 0, "y": -20, "orientation": "n", "name": "A1", "type": "Inner"}],
link_type="master", informations={"type": "coil"}, uuid=U)
self.assertEqual(set(r), {"ok", "output", "bytes", "width", "height", "hotspot_x",
"hotspot_y", "bbox", "terminal_index_order", "part_uuids",
"verified"})
self.assertTrue(r["ok"])
self.assertEqual(r["output"], str(out))
self.assertEqual(r["bytes"], out.stat().st_size)
self.assertEqual(r["terminal_index_order"], ["A1", "A2"])
root = ET.parse(out).getroot()
self.assertEqual(dict(root.attrib), {
"version": "0.100.0", "type": "element", "link_type": "master",
"width": str(r["width"]), "height": str(r["height"]),
"hotspot_x": str(r["hotspot_x"]), "hotspot_y": str(r["hotspot_y"])})
self.assertEqual(root.find("uuid").get("uuid"), U)
self.assertEqual([(n.get("lang"), n.text) for n in root.iter("name")],
[("en", "Coil"), ("fr", "Bobine")])
self.assertEqual([(k.get("name"), k.text) for k in root.iter("kindInformation")],
[("type", "coil")])
t2, t1 = root.findall("description/terminal")
self.assertEqual({k: v for k, v in t1.attrib.items() if k != "uuid"},
{"x": "0", "y": "-20", "orientation": "n", "type": "Inner", "name": "A1"})
self.assertEqual(t2.get("type"), "Generic")
self.assertRegex(t1.get("uuid"), m._UUID_RE)
self.assertNotEqual(t1.get("uuid"), t2.get("uuid"))
def test_build_refusals(self):
out = str(self.root / "x.elmt")
line = [{"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0}]
term = [{"x": 0, "y": 0, "orientation": "n"}]
for message, kwargs in [
("names must be a non-empty", dict(names={}, parts=line, terminals=term)),
("unknown link_type", dict(names={"en": "a"}, parts=line, terminals=term, link_type="x")),
("parts must be a list", dict(names={"en": "a"}, parts={}, terminals=term)),
("terminal 0 is not an object", dict(names={"en": "a"}, parts=line, terminals=[1])),
("terminal 0 is missing 'orientation'", dict(names={"en": "a"}, parts=line,
terminals=[{"x": 0, "y": 0}])),
("orientation is one of", dict(names={"en": "a"}, parts=line,
terminals=[{"x": 0, "y": 0, "orientation": "up"}])),
("cannot be connected", dict(names={"en": "a"}, parts=line, terminals=[])),
("same uuid", dict(names={"en": "a"}, terminals=term, parts=[
dict(line[0], uuid="{11111111-2222-4333-8444-555555555555}"),
dict(line[0], uuid="{11111111-2222-4333-8444-555555555555}")]))]:
with self.subTest(message=message):
with self.assertRaisesRegex(ValueError, message):
m.tool_element_build(out, **kwargs)
def test_element_info_exactly(self):
p = self.root / "e.elmt"
p.write_text(
''
'E'
' label '
''
''
''
'',
encoding="utf-8")
r = m.tool_element_info(str(p))
self.assertEqual(set(r), {"file", "type", "link_type", "width", "height", "names",
"terminal_count", "terminals", "terminal_order",
"info_fields", "parts", "part_list"})
self.assertEqual((r["file"], r["type"], r["link_type"], r["width"], r["height"]),
(str(p), "element", "simple", "20", "40"))
self.assertEqual(r["terminal_count"], 3)
self.assertEqual(r["terminals"][0], {"index": 0, "x": "0", "y": "-10", "orientation": "n",
"name": "1", "type": "", "uuid": ""})
self.assertEqual(r["terminals"][2], {"index": 2, "x": "0", "y": "10", "orientation": "s",
"name": "2", "type": "Generic", "uuid": "{B}"})
self.assertEqual(r["info_fields"], ["label"])
self.assertEqual(r["parts"], {"line": 1, "terminal": 3, "arc": 1})
self.assertEqual(r["part_list"], [{"type": "line", "uuid": "{L}"}, {"type": "arc", "uuid": ""}])
self.assertNotIn("undefined", r["terminal_order"])
p.write_text(p.read_text().replace('x="5" y="-10"', 'x="0" y="-10"'), encoding="utf-8")
self.assertIn("undefined", m.tool_element_info(str(p))["terminal_order"])
def test_index_entry_exactly(self):
f = self.root / "a" / "k.elmt"
f.parent.mkdir()
f.write_text(''
'Bobine'
' coil '
'x'
''
'',
encoding="utf-8")
(self.root / "a" / "broken.elmt").write_text("", encoding="utf-8")
items = m._index_collection(self.root)
self.assertEqual(len(items), 1)
it = {k: v for k, v in items[0].items() if k != "haystack"}
self.assertEqual(it, {"path": "common://a/k.elmt", "file": str(f), "name": "Bobine",
"names": {"fr": "Bobine"}, "link_type": "master", "kind": "coil",
"terminals": 2, "terminal_names": ["A2", "A1"],
"terminal_order_ambiguous": True, "width": "30", "height": "50"})
# the index is cached until the collection changes
self.assertIs(m._index_collection(self.root), items)
self.assertEqual(m._collection_signature(self.root)[0], 3)
def test_search_ranking_and_limit(self):
for rel, name in (("a/1.elmt", "Coil latching"), ("a/2.elmt", "Coil"),
("a/3.elmt", "Remanence coil")):
f = self.root / rel
f.parent.mkdir(exist_ok=True)
f.write_text(f''
f'{name}'
f'',
encoding="utf-8")
r = m.tool_element_search(str(self.root), "coil")
self.assertEqual([e["name"] for e in r["results"]],
["Coil", "Coil latching", "Remanence coil"])
r = m.tool_element_search(str(self.root), "coil", limit=1)
self.assertEqual((r["total_matches"], r["returned"]), (3, 1))
with self.assertRaisesRegex(ValueError, "limit must be >= 1"):
m.tool_element_search(str(self.root), "coil", limit=0)
self.assertEqual(m.tool_element_search(str(self.root), "coil", limit=1)["results"][0]["name"],
"Coil")
self.assertEqual(set(r), {"query", "total_matches", "returned", "indexed", "results"})
self.assertEqual((r["query"], r["indexed"]), ("coil", 3))
self.assertEqual(r["results"][0]["languages"], ["en"])
def test_search_puts_the_exact_name_then_names_starting_with_it(self):
for n, name in enumerate(("Relay coil", "Coil relay", "Coil relay X", "A coil relay")):
f = self.root / f"{n}.elmt"
f.write_text(f''
f'{name}'
f'',
encoding="utf-8")
r = m.tool_element_search(str(self.root), "coil relay")
self.assertEqual([e["name"] for e in r["results"]],
["Coil relay", "Coil relay X", "Relay coil", "A coil relay"])
def test_search_returns_25_by_default(self):
for n in range(26):
(self.root / f"{n:02}.elmt").write_text(
f'Coil {n}'
f''
f'', encoding="utf-8")
r = m.tool_element_search(str(self.root), "coil")
self.assertEqual((r["total_matches"], r["returned"]), (26, 25))
def test_search_exact_name_wins_a_tie(self):
"""Same length, same first word: the exact name comes first, not
the one whose path sorts first."""
for rel, name in (("a.elmt", "Coil-a"), ("z.elmt", "Coil a")):
(self.root / rel).write_text(
f'{name}'
f''
f'', encoding="utf-8")
r = m.tool_element_search(str(self.root), "coil a")
self.assertEqual([e["name"] for e in r["results"]], ["Coil a", "Coil-a"])
def test_validate_part_refusals(self):
ok = lambda part: m._validate_part(0, part)
self.assertEqual(ok({"type": "polygon", "points": [[0, 0], [1, 1]]}), "polygon")
for message, part in [
("not an object", ["line"]),
("at least two points", {"type": "polygon", "points": [[0, 0]]}),
("at least two points", {"type": "polygon", "points": "ab"}),
("each point is", {"type": "polygon", "points": [[0, 0], [1, 1, 1]]}),
("each point is", {"type": "polygon", "points": [[0, 0], 5]}),
("uuid must look like", {"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 1,
"uuid": "L1"})]:
with self.subTest(message=message):
with self.assertRaisesRegex(ValueError, message):
ok(part)
def test_terminal_order_with_unreadable_coordinates(self):
T = lambda **a: ET.Element("terminal", {k: str(v) for k, v in a.items()})
ordered, _ = m._terminals_in_index_order([T(x=0, y=0.5, name="b"), T(x=0, y="bad", name="a")])
self.assertEqual([t.get("name") for t in ordered], ["a", "b"])
ordered, _ = m._terminals_in_index_order([T(x=0.5, y=0, name="b"), T(x="bad", y=0, name="a")])
self.assertEqual([t.get("name") for t in ordered], ["a", "b"])
# a missing coordinate counts as 0
ordered, _ = m._terminals_in_index_order([T(x=0, y=0.5, name="b"), T(x=0, name="a")])
self.assertEqual([t.get("name") for t in ordered], ["a", "b"])
ordered, _ = m._terminals_in_index_order([T(x=0.5, y=0, name="b"), T(y=0, name="a")])
self.assertEqual([t.get("name") for t in ordered], ["a", "b"])
class CheckAndContinuityAnswers(unittest.TestCase):
"""qet_check and qet_continuity turn QElectroTech's log lines into their
answer. With _run_qet replaced by a stub that returns chosen lines, the
whole answer can be checked exactly, without QElectroTech. Found by a
mutation audit: the binary tests look at a finding or two, so a wrong
summary count, a dropped field or a wrong "passed" went unnoticed."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.qet = Path(self.tmp.name) / "p.qet"
self.qet.write_text('',
encoding="utf-8")
def tearDown(self):
self.tmp.cleanup()
def stub(self, lines, **extra):
out = "\n".join(["noise", m._MARKER + "{not json"] +
[m._MARKER + json.dumps(l) for l in lines])
return mock.patch.object(m, "_run_qet", lambda *a, **k: {"stdout": out, "stderr": "", **extra})
def test_check_answer_exactly(self):
rows = [{"label": f"K{i}"} for i in range(12)]
lines = [
{"kind": "check", "name": "duplicate_master_labels", "rows": rows, "error": ""},
{"kind": "check", "name": "unlabelled_masters", "rows": [{"x": 1}], "error": ""},
{"kind": "check", "name": "unnumbered_conductors", "rows": [{"n": 1}, {"n": 2}], "error": ""},
{"kind": "check", "name": "duplicate_simple_labels", "rows": [], "error": ""},
{"kind": "check", "name": "empty_folios", "rows": None, "error": "bad SQL"},
{"kind": "check", "name": "crowded_terminals", "rows": [], "error": ""},
{"kind": "check", "name": "reports_with_several_wires", "rows": [], "error": ""},
{"kind": "other", "name": "masters_without_manufacturer_reference", "rows": [1]},
]
with self.stub(lines):
r = m.tool_check("qet", str(self.qet)) # sample defaults to 10
C = m.CHECKS
self.assertEqual(r, {
"ok": False,
"summary": {"errors": 1, "warnings": 1, "info": 1, "passed": 3, "check_failures": 2},
"findings": [
{"check": "duplicate_master_labels", "severity": "error", "count": 12,
"note": C["duplicate_master_labels"]["note"], "rows": rows[:10]},
{"check": "unlabelled_masters", "severity": "warning", "count": 1,
"note": C["unlabelled_masters"]["note"], "rows": [{"x": 1}]},
{"check": "unnumbered_conductors", "severity": "info", "count": 2,
"note": C["unnumbered_conductors"]["note"], "rows": [{"n": 1}, {"n": 2}]}],
"passed": ["duplicate_simple_labels", "crowded_terminals", "reports_with_several_wires"],
"check_failures": [
{"check": "empty_folios", "error": "bad SQL"},
{"check": "masters_without_manufacturer_reference", "error": "no result came back"}]})
def test_check_sorts_by_severity_then_name(self):
lines = [{"kind": "check", "name": "unlabelled_masters", "rows": [1], "error": ""},
{"kind": "check", "name": "empty_folios", "rows": [1], "error": ""}]
with self.stub(lines):
r = m.tool_check("qet", str(self.qet), checks=["empty_folios", "unlabelled_masters"])
self.assertEqual([f["check"] for f in r["findings"]], ["unlabelled_masters", "empty_folios"])
def test_check_failure_alone_is_not_ok(self):
lines = [{"kind": "check", "name": "empty_folios", "rows": None, "error": "bad SQL"}]
with self.stub(lines):
r = m.tool_check("qet", str(self.qet), checks=["empty_folios"])
self.assertFalse(r["ok"])
def test_check_ignores_a_line_without_the_marker(self):
almost = "x" * (len(m._MARKER) - 1) + json.dumps(
{"kind": "check", "name": "empty_folios", "rows": [1], "error": ""})
with mock.patch.object(m, "_run_qet", lambda *a, **k: {"stdout": almost, "stderr": ""}):
r = m.tool_check("qet", str(self.qet), checks=["empty_folios"])
self.assertEqual(r["check_failures"], [{"check": "empty_folios", "error": "no result came back"}])
def test_check_ok_without_errors_and_sample_zero(self):
lines = [{"kind": "check", "name": "unlabelled_masters", "rows": [{"x": 1}], "error": ""}]
with self.stub(lines):
r = m.tool_check("qet", str(self.qet), checks=["unlabelled_masters"], sample=0)
self.assertTrue(r["ok"]) # a warning is not a failure
self.assertEqual(r["findings"][0]["rows"], [])
self.assertEqual(r["findings"][0]["count"], 1)
with self.assertRaisesRegex(ValueError, "sample must be >= 0"):
m.tool_check("qet", str(self.qet), sample=-1)
with self.assertRaisesRegex(ValueError, "no such project"):
m.tool_check("qet", str(self.qet) + ".missing")
def test_check_carries_the_launch_hint(self):
with self.stub([], hint="why it did not start"):
r = m.tool_check("qet", str(self.qet), checks=["empty_folios"])
self.assertFalse(r["ok"])
self.assertEqual(r["hint"], "why it did not start")
def test_continuity_answer_exactly(self):
found = [{"severity": "error", "folio": 1, "what": "a"},
{"severity": "warning", "folio": 0, "what": "b"},
{"severity": "info", "folio": "?", "what": "c"},
{"severity": "info", "what": "d"},
{"severity": "info", "folio": 1, "what": "e"}]
with self.stub([{"kind": "continuity", "findings": found},
{"kind": "other", "findings": []}]):
r = m.tool_continuity("qet", str(self.qet), folio=1)
self.assertEqual((r["finding_count"], r["errors"], r["warnings"], r["info"]), (5, 1, 1, 3))
self.assertEqual([f.get("folio_number") for f in r["findings"]], [2, 1, None, None, 2])
self.assertNotIn(m._MARKER, r["stdout"])
self.assertIn("noise", r["stdout"])
def test_continuity_passes_the_folio_to_the_script(self):
seen = []
def fake(binary, args, **kw):
seen.append(kw["script"])
return {"stdout": m._MARKER + json.dumps({"kind": "continuity", "findings": []}),
"stderr": ""}
with mock.patch.object(m, "_run_qet", fake):
m.tool_continuity("qet", str(self.qet))
m.tool_continuity("qet", str(self.qet), folio=1)
self.assertIn("qet.checkContinuity(-1)", seen[0])
self.assertIn("qet.checkContinuity(1)", seen[1])
with self.assertRaisesRegex(ValueError, "no such project"):
m.tool_continuity("qet", str(self.qet) + ".missing")
almost = "x" * (len(m._MARKER) - 1) + json.dumps({"kind": "continuity", "findings": []})
with mock.patch.object(m, "_run_qet", lambda *a, **k: {"stdout": almost, "stderr": ""}):
self.assertFalse(m.tool_continuity("qet", str(self.qet))["ok"])
def test_continuity_without_findings_is_not_ok(self):
with self.stub([]):
r = m.tool_continuity("qet", str(self.qet))
self.assertFalse(r["ok"])
self.assertIn("predate qet.checkContinuity()", r["hint"])
with self.stub([], hint="launch failed"):
self.assertEqual(m.tool_continuity("qet", str(self.qet))["hint"], "launch failed")
def test_continuity_folio_bounds(self):
for bad in (-1, 2):
with self.subTest(folio=bad):
with self.assertRaisesRegex(ValueError, f"folio {bad} does not exist"):
m.tool_continuity("qet", str(self.qet), folio=bad)
with self.stub([{"kind": "continuity", "findings": []}]):
self.assertEqual(m.tool_continuity("qet", str(self.qet), folio=0)["finding_count"], 0)
def _sym(uuid, x, y, w=20, h=40, label="", terminals=2, name="S"):
"""A symbol for the layout rules: origin x/y, box centred on it."""
return {"uuid": uuid, "name": name, "label": label, "terminals": terminals,
"g": {"x": x, "y": y, "rotation": 0, "left": x - w / 2, "top": y - h / 2,
"right": x + w / 2, "bottom": y + h / 2}}
def _wire(uuid, a, b, points):
return {"uuid": uuid, "ends": [f"{a} terminal 1", f"{b} terminal 0"],
"path": [{"x": x, "y": y} for x, y in points], "segs": None}
def _vjog(uuid, a, b, xa, xb, y0=120, y1=160):
"""A wire leaving a's bottom terminal down and entering b's top one."""
mid = (y0 + y1) / 2
return _wire(uuid, a, b, [(xa, y0), (xa, y0 + 10), (xa, mid), (xb, mid),
(xb, y1 - 10), (xb, y1)])
class LayoutRules(unittest.TestCase):
"""qet_layout_check's scoring and fix planning, on made-up geometry: no
QElectroTech needed, so every rule is pinned exactly."""
def folio(self, elements, conductors, max_shift=40, folio=0):
return m._layout_folio({"folio": folio, "elements": elements,
"conductors": conductors}, max_shift)
def rules(self, r):
return sorted(f["rule"] for f in r["findings"])
def test_simplify_drops_zero_steps_and_merges_runs(self):
self.assertEqual(m._simplify([(0, 0), (0, 10), (0, 10), (0, 30), (5, 30), (9, 30)]),
[(0, 0), (0, 30), (9, 30)])
def test_points_from_segments_and_from_path(self):
segs = ["0: (560,150)-(560,160) vertical static",
"1: (560,160)-(560,290.5) vertical movable"]
self.assertEqual(m._layout_points({"segs": segs}),
[(560.0, 150.0), (560.0, 160.0), (560.0, 290.5)])
self.assertEqual(m._layout_points({"path": [{"x": 1, "y": 2}, {"x": 1, "y": 9}]}),
[(1.0, 2.0), (1.0, 9.0)])
self.assertIsNone(m._layout_points({"segs": ["garbage"]}))
self.assertIsNone(m._layout_points({"segs": None, "path": None}))
def test_vertical_jog_moves_the_end_that_lands_on_the_grid(self):
r = self.folio([_sym("A", 100, 100), _sym("B", 103, 180)],
[_vjog("W", "A", "B", 100, 103)])
[f] = r["findings"]
self.assertEqual(f["rule"], "avoidable_bend")
self.assertEqual((f["offset"], f["bends"], f["folio"]), (3.0, 2, 1))
self.assertEqual(f["fix"], {"op": "move_element", "folio": 0, "element": "B",
"dx": -3.0, "dy": 0.0})
self.assertEqual(r["fixes"], [f["fix"]])
def test_horizontal_jog_nfpa(self):
w = _wire("W", "A", "B", [(110, 100), (120, 100), (130, 100), (130, 96),
(140, 96), (150, 96)])
r = self.folio([_sym("A", 100, 100, 20, 20), _sym("B", 160, 96, 20, 20)], [w])
[f] = r["findings"]
self.assertEqual(f["fix"], {"op": "move_element", "folio": 0, "element": "B",
"dx": 0.0, "dy": 4.0})
def test_terminals_not_facing_are_not_a_jog(self):
# both terminals send their wire downwards: a U, never straight
w = _wire("W", "A", "B", [(100, 120), (100, 140), (103, 140), (103, 120)])
r = self.folio([_sym("A", 100, 100), _sym("B", 103, 100, 2, 2)], [w])
self.assertNotIn("avoidable_bend", self.rules(r))
def test_jog_beyond_max_shift_is_left_alone(self):
r = self.folio([_sym("A", 100, 100), _sym("B", 160, 180)],
[_vjog("W", "A", "B", 100, 160)], max_shift=40)
self.assertEqual(r["findings"], [])
self.assertEqual(r["fixes"], [])
def test_a_straight_wire_pins_its_symbols(self):
# A-B straight; B-C jogs: C moves, not B
ab = _wire("AB", "A", "B", [(100, 120), (100, 160)])
bc = _vjog("BC", "B", "C", 100, 104, 200, 240)
r = self.folio([_sym("A", 100, 100), _sym("B", 100, 180), _sym("C", 104, 260)], [ab, bc])
self.assertEqual(r["fixes"], [{"op": "move_element", "folio": 0, "element": "C",
"dx": -4.0, "dy": 0.0}])
def test_conflict_when_both_ends_are_pinned(self):
# A and B each held in line by a straight wire; the A-B jog cannot move
wires = [_wire("AX", "X", "A", [(100, 40), (100, 80)]),
_wire("BY", "B", "Y", [(104, 200), (104, 240)]),
_vjog("AB", "A", "B", 100, 104)]
r = self.folio([_sym("X", 100, 20), _sym("A", 100, 100), _sym("B", 104, 180),
_sym("Y", 104, 260)], wires)
[f] = [f for f in r["findings"] if f["rule"] == "avoidable_bend"]
self.assertIsNone(f["fix"])
self.assertTrue(f["conflict"])
def test_a_move_onto_another_symbol_is_not_offered(self):
# B can only line up by moving onto D, so A moves instead
r = self.folio([_sym("A", 100, 100), _sym("B", 120, 180), _sym("D", 100, 180)],
[_vjog("W", "A", "B", 100, 120)])
[f] = [f for f in r["findings"] if f["rule"] == "avoidable_bend"]
self.assertEqual(f["fix"]["element"], "A")
self.assertEqual(f["fix"]["dx"], 20.0)
def test_off_grid_symbol_without_wires(self):
r = self.folio([_sym("A", 103, 97)], [])
[f] = r["findings"]
self.assertEqual(f["rule"], "off_grid")
self.assertEqual(f["fix"], {"op": "move_element", "folio": 0, "element": "A",
"dx": -3.0, "dy": 3.0})
def test_a_lined_up_group_snaps_together(self):
# three symbols in line at x=103, off the grid together: all move,
# and the wires between them stay straight
wires = [_wire("AB", "A", "B", [(103, 120), (103, 160)]),
_wire("BC", "B", "C", [(103, 200), (103, 240)])]
r = self.folio([_sym("A", 103, 100), _sym("B", 103, 180), _sym("C", 103, 260)], wires)
self.assertEqual(sorted((f["element"], f["dx"]) for f in r["fixes"]),
[("A", -3.0), ("B", -3.0), ("C", -3.0)])
def test_wire_through_symbol_but_not_frame_or_annotation(self):
w = _wire("W", "A", "B", [(100, 120), (100, 300)])
elements = [_sym("A", 100, 100), _sym("B", 100, 320),
_sym("K", 100, 200, name="Coil"), # in the way
_sym("F", 100, 200, 300, 600, name="Cabinet"), # frame round A
_sym("T", 100, 250, terminals=0, name="Tag")] # annotation
r = self.folio(elements, [w])
hits = [f for f in r["findings"] if f["rule"] == "wire_through_symbol"]
self.assertEqual([f["element"] for f in hits], ["K"])
self.assertEqual(hits[0]["fix"], {"op": "route_conductor", "folio": 0,
"conductor": "W"})
def test_overlap_needs_more_than_one_grid_step(self):
touching = self.folio([_sym("A", 100, 100), _sym("B", 110, 100)], []) # 10 px
self.assertNotIn("overlapping_symbols", self.rules(touching))
r = self.folio([_sym("A", 100, 100), _sym("B", 105, 100)], []) # 15 px
self.assertIn("overlapping_symbols", self.rules(r))
def test_crossing_counted_not_at_shared_ends(self):
h = _wire("H", "A", "B", [(0, 50), (200, 50)])
v = _wire("V", "C", "D", [(100, 0), (100, 200)])
t = _wire("T", "A", "E", [(0, 50), (0, 200)]) # meets H at its end
r = self.folio([], [h, v, t])
self.assertEqual(r["crossings"], 1)
def test_extra_bends_for_an_l(self):
w = _wire("W", "A", "B", [(100, 120), (100, 140), (120, 140), (120, 160),
(150, 160)])
r = self.folio([], [w])
[f] = r["findings"]
self.assertEqual((f["rule"], f["bends"], f["needed"]), ("extra_bends", 3, 1))
def test_answer_score_style_and_limit(self):
clean = self.folio([_sym("A", 100, 100), _sym("B", 100, 180)],
[_wire("W", "A", "B", [(100, 120), (100, 160)])])
a = m._layout_answer([clean], "auto", 50)
self.assertEqual((a["score"], a["style"], a["summary"]["straight_wires"]), (100, "iec", 1))
self.assertEqual(a["summary"]["flow"], {"vertical": 1.0, "horizontal": 0.0})
jog = self.folio([_sym("A", 100, 100), _sym("B", 103, 180), _sym("C", 300, 301)],
[_vjog("W", "A", "B", 100, 103)])
a = m._layout_answer([jog], "nfpa", 1)
# wires 0/1 clean, symbols 2/3 clean (C off the grid)
self.assertEqual(a["score"], round(100 * (0.6 * 0 + 0.4 * 2 / 3)))
self.assertEqual(a["style"], "nfpa")
self.assertEqual(a["truncated"], 1)
self.assertEqual(len(a["fixes"]), 2)
def test_unread_wires_are_named_not_scored(self):
r = self.folio([], [{"uuid": "U", "ends": ["{a} terminal 0", "{b} terminal 0"],
"path": None, "segs": None}])
a = m._layout_answer([r], "auto", 50)
self.assertEqual(a["summary"]["unread_wires"], 1)
self.assertEqual(a["unread_wires"], ["U"])
self.assertIn("conductorPath", a["note"])
self.assertEqual(a["score"], 100)
class LayoutCheckTool(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.qet = Path(self.tmp.name) / "p.qet"
self.qet.write_text("", encoding="utf-8")
def tearDown(self):
self.tmp.cleanup()
def test_bad_arguments(self):
for kw in ({"style": "ansi"}, {"folio": 0}, {"folio": True},
{"max_shift": -1}, {"max_shift": "4"}, {"limit": -1}):
with self.subTest(kw=kw), self.assertRaises(ValueError):
m.tool_layout_check("qet", str(self.qet), **kw)
with self.assertRaises(ValueError):
m.tool_layout_check("qet", str(self.qet) + ".missing")
def test_script_reads_the_chosen_folio_only(self):
seen = {}
def run(binary, args, **kw):
seen.update(kw)
return {"stdout": "", "stderr": ""}
with mock.patch.object(m, "_run_qet", run):
r = m.tool_layout_check("qet", str(self.qet), folio=3)
self.assertIn("var only = 2;", seen["script"])
self.assertNotIn("save", seen["script"])
self.assertFalse(r["ok"])
self.assertIn("no layout came back", r["hint"])
def test_answer_from_log_lines(self):
rec = {"kind": "layout", "folio": 0,
"elements": [_sym("A", 100, 100), _sym("B", 103, 180)],
"conductors": [_vjog("W", "A", "B", 100, 103)]}
out = "noise\n" + m._MARKER + "{bad json\n" + m._MARKER + json.dumps(rec)
with mock.patch.object(m, "_run_qet", lambda *a, **k: {"stdout": out, "stderr": ""}):
r = m.tool_layout_check("qet", str(self.qet))
self.assertTrue(r["ok"])
self.assertEqual(r["summary"]["avoidable_bends"], 1)
self.assertEqual(r["fixes"][0]["element"], "B")
def test_a_launch_hint_is_passed_on(self):
rec = {"kind": "layout", "folio": 0, "elements": [], "conductors": []}
out = m._MARKER + json.dumps(rec)
with mock.patch.object(m, "_run_qet",
lambda *a, **k: {"stdout": out, "stderr": "", "hint": "boom"}):
r = m.tool_layout_check("qet", str(self.qet))
self.assertFalse(r["ok"])
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()
self.root = Path(self.tmp.name)
m._ELEMENT_INDEX.clear()
def tearDown(self):
self.tmp.cleanup()
def put(self, rel, names, link="simple", terminals=((0, -10, "n"), (0, 10, "s")), kind=""):
p = self.root / rel
p.parent.mkdir(parents=True, exist_ok=True)
kinds = f'{kind}' if kind else ""
ns = "".join(f'{n}' for l, n in names.items())
ts = "".join(f''
for i, (x, y, o) in enumerate(terminals))
p.write_text(f'{ns}{kinds}'
f'{ts}', encoding="utf-8")
def test_accents_and_case_are_ignored_across_languages(self):
self.put("a/res.elmt", {"en": "Resistor", "fr": "Résistance", "de": "Widerstand"})
for q in ("resistance", "RÉSISTANCE", "widerstand", "resistor"):
with self.subTest(q=q):
self.assertEqual(m.tool_element_search(str(self.root), q)["total_matches"], 1)
def test_every_word_must_match(self):
self.put("a/x.elmt", {"en": "Big red coil"})
self.assertEqual(m.tool_element_search(str(self.root), "red coil")["total_matches"], 1)
self.assertEqual(m.tool_element_search(str(self.root), "red fuse")["total_matches"], 0)
def test_a_two_letter_word_matches_only_a_whole_word(self):
"""#1178: "NC contact" offered a remanence coil, because "nc" is in
"remanence" and "contact" is in its folder's name."""
self.put("contacts/coil.elmt", {"en": "Remanence coil"})
self.put("contacts/nc.elmt", {"en": "Simple contact (NC)"})
self.put("contacts/klemme.elmt", {"de": "Reihenklemme 3pn"})
names = lambda q: [r["path"] for r in m.tool_element_search(str(self.root), q)["results"]]
self.assertEqual(names("NC contact"), ["common://contacts/nc.elmt"])
# longer words, and short ones with a digit, still match inside a word
self.assertEqual(names("klemme"), ["common://contacts/klemme.elmt"])
self.assertEqual(names("3p"), ["common://contacts/klemme.elmt"])
def test_every_spelling_of_no_and_nc_is_the_same(self):
self.put("a/nf.elmt", {"fr": "Contact simple (NF)"})
self.put("a/no.elmt", {"en": "Contact N/O"})
self.put("a/x_nc.elmt", {"en": "Contact"})
self.put("a/open.elmt", {"en": "Normally open contact"})
g = lambda q: sorted(r["path"][len("common://a/"):]
for r in m.tool_element_search(str(self.root), q)["results"])
for q in ("NC contact", "normally closed contact", "NF contact", "contact normalement fermé"):
with self.subTest(q=q):
self.assertEqual(g(q), ["nf.elmt", "x_nc.elmt"])
for q in ("NO contact", "normally open contact", "N/O contact", "contact normalement ouvert"):
with self.subTest(q=q):
self.assertEqual(g(q), ["no.elmt", "open.elmt"])
def test_schematic_symbols_come_before_drawings_and_makers_parts(self):
"""#1178: "emergency stop" offered a maker's part and two
assembly-plan drawings before the push button."""
self.put("10_electric/20_manufacturers_articles/idec/my_emg.elmt", {"en": "Emergency stop"})
self.put("10_electric/98_graphics/99_assembly_plan/au.elmt", {"en": "Emergency stop"})
self.put("10_electric/99_miscellaneous_unsorted/au.elmt", {"en": "Emergency stop"})
self.put("10_electric/10_allpole/20_push_buttons/au.elmt", {"en": "Emergency stop (NC)"})
self.put("cadtb/estop.elmt", {"en": "Emergency stop, own"})
r = m.tool_element_search(str(self.root), "emergency stop")["results"]
self.assertEqual([e["path"] for e in r[:2]],
["common://10_electric/10_allpole/20_push_buttons/au.elmt",
"common://cadtb/estop.elmt"])
self.assertEqual(len(r), 5)
def test_filters(self):
self.put("a/m.elmt", {"en": "Coil"}, link="master", kind="coil")
self.put("a/s.elmt", {"en": "Contact"}, link="slave", terminals=((0, 0, "n"),) * 1)
self.put("a/t.elmt", {"en": "Big"}, terminals=[(0, i, "n") for i in range(5)])
g = lambda **k: {r["name"] for r in m.tool_element_search(str(self.root), **k)["results"]}
self.assertEqual(g(link_type="master"), {"Coil"})
self.assertEqual(g(min_terminals=5), {"Big"})
self.assertEqual(g(max_terminals=1), {"Contact"})
self.assertEqual(g(kind="coil"), {"Coil"})
def test_result_is_placeable_path_and_index_ordered_terminals(self):
self.put("dir/x.elmt", {"en": "X"}, terminals=((0, 20, "s"), (0, -20, "n")))
r = m.tool_element_search(str(self.root), "x")["results"][0]
self.assertEqual(r["path"], "common://dir/x.elmt")
self.assertEqual(r["terminal_names"], ["t1", "t0"]) # t1 is at y=-20: index 0
def test_new_file_is_found_without_a_manual_reindex(self):
"""A symbol written by qet_element_build must be searchable at once."""
self.put("a/one.elmt", {"en": "One"})
self.assertEqual(m.tool_element_search(str(self.root), "two")["total_matches"], 0)
self.put("a/two.elmt", {"en": "Two"})
self.assertEqual(m.tool_element_search(str(self.root), "two")["total_matches"], 1)
def test_exact_name_ranks_first(self):
self.put("a/long.elmt", {"en": "Remanence coil, latching"})
self.put("a/short.elmt", {"en": "Coil"})
self.assertEqual(m.tool_element_search(str(self.root), "coil")["results"][0]["name"], "Coil")
def test_bad_arguments(self):
with self.assertRaises(ValueError):
m.tool_element_search("/no/such/dir")
with self.assertRaises(ValueError):
m.tool_element_search(str(self.root), link_type="widget")
with self.assertRaises(ValueError):
m.tool_element_search(str(self.root), limit=0)
def test_unparseable_files_are_skipped_not_fatal(self):
(self.root / "bad.elmt").write_text("'
''
'')
def tearDown(self):
self.tmp.cleanup()
def hint(self, op):
return m._wrong_folio(str(self.qet), op)
def test_the_number_qet_elements_showed_gets_the_index_to_use(self):
h = self.hint({"op": "move_element", "folio": 2, "element": "{b}", "dx": 5, "dy": 0})
self.assertIn("folio 2 as qet_elements numbers it", h)
self.assertIn('"folio": 1 here', h)
def test_no_hint_when_the_folio_was_right_or_cannot_be_checked(self):
for op in ({"op": "move_element", "folio": 1, "element": "{b}"}, # right index
{"op": "move_element", "folio": 0, "element": "{nope}"}, # not in the file
{"op": "move_element", "folio": 0, "element": "$k1"}, # placed by the script
{"op": "move_element", "folio": "$f", "element": "{b}"}, # folio by reference
{"op": "add_folio"}):
with self.subTest(op=op):
self.assertEqual(self.hint(op), "")
def test_the_description_says_how_folios_are_counted(self):
tool = next(t for t in m.TOOLS if t["name"] == "qet_edit")
self.assertIn("counted from 0", tool["inputSchema"]["properties"]["operations"]["description"])
class ReadToolContracts(unittest.TestCase):
"""Exact answers of the read tools on a small hand-made project.
A mutation audit (planting small bugs in the server and running this
suite) found qet_elements, qet_conductors and qet_project_info almost
untested: a dropped output field, a filter that never applied or a
count off by one all passed. Every value here is pinned exactly."""
A = "{aaaaaaaa-0000-4000-8000-000000000001}"
B = "{bbbbbbbb-0000-4000-8000-000000000002}"
C = "{cccccccc-0000-4000-8000-000000000003}"
D = "{dddddddd-0000-4000-8000-000000000004}"
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.qet = str(Path(self.tmp.name) / "p.qet")
A, B, C, D = self.A, self.B, self.C, self.D
Path(self.qet).write_text(
''
''
f''
''
''
''
'K1'
' spaced '
'no name, ignored'
''
f''
f''
''
''
f''
''
''
f''
''
f''
'')
def tearDown(self):
self.tmp.cleanup()
def test_project_info_exact(self):
self.assertEqual(m.tool_project_info(self.qet), {
"file": self.qet, "title": "T", "version": "0.100",
"folio_count": 2, "element_count": 4, "conductor_count": 3,
"folios": [{"index": 1, "uuid": "", "title": "One", "elements": 3, "conductors": 2},
{"index": 2, "uuid": "", "title": "Two", "elements": 1, "conductors": 1}]})
def test_elements_rows_exact(self):
r = m.tool_elements(self.qet)
self.assertEqual((r["count"], r["truncated"]), (4, False))
self.assertEqual(r["elements"][0], {
"folio": 1, "uuid": self.A, "type": "embed://import/x/Coil.elmt", "name": "Coil",
"x": "10", "y": "20", "label": "K1",
# values as QElectroTech saves them (trimmed); a field with no name is skipped
"info": {"label": "K1", "comment": "spaced"}})
self.assertEqual([(e["folio"], e["name"], e["label"], e["info"]) for e in r["elements"][1:]],
[(1, "relay_coil", "", {}), (1, "lamp", "", {}), (2, "Coil", "", {})])
def test_elements_filters_and_limit(self):
names = lambda r: [e["name"] for e in r["elements"]]
self.assertEqual(names(m.tool_elements(self.qet, folio=2)), ["Coil"])
self.assertEqual(names(m.tool_elements(self.qet, folio=1)), ["Coil", "relay_coil", "lamp"])
# case-insensitive substring of the definition's file name
self.assertEqual(names(m.tool_elements(self.qet, name_contains="COIL")),
["Coil", "relay_coil", "Coil"])
self.assertEqual(names(m.tool_elements(self.qet, folio=1, name_contains="coil")),
["Coil", "relay_coil"])
r = m.tool_elements(self.qet, limit=2)
self.assertEqual((r["count"], r["truncated"], len(r["elements"])), (4, True, 2))
r = m.tool_elements(self.qet, limit=4)
self.assertEqual((r["count"], r["truncated"], len(r["elements"])), (4, False, 4))
def test_conductor_rows_exact(self):
r = m.tool_conductors(self.qet)
self.assertEqual((r["count"], r["truncated"]), (3, False))
first, second, third = r["conductors"]
self.assertEqual(first, {
"folio": 1, "uuid": "", "num": "W1", "formula": "F", "cable": "C1", "bus": "B",
"function": "L1", "color": "red", "section": "1.5", "type": "multi",
# legacy ends resolved through the folio's terminal ids
"key": f"1:{self.A}@0,-10,n--{self.A}@0,10,s"})
# current ends: instance/terminal; an empty terminal falls back to its name
self.assertEqual(second["key"], f"1:{self.B}/A1--{self.C}/{{t2}}")
self.assertEqual((second["num"], second["cable"]), ("W2", " "))
self.assertEqual((third["folio"], third["key"]), (2, f"2:{self.D}/{{t1}}--{self.D}/{{t2}}"))
def test_conductor_filters_and_limit(self):
nums = lambda r: [c["num"] for c in r["conductors"]]
self.assertEqual(nums(m.tool_conductors(self.qet, folio=1)), ["W1", "W2"])
self.assertEqual(nums(m.tool_conductors(self.qet, folio=2)), [""])
r = m.tool_conductors(self.qet, attribute="cable")
self.assertEqual([c["value"] for c in r["conductors"]], ["C1", " ", ""])
# non_empty drops empty and whitespace-only values
r = m.tool_conductors(self.qet, attribute="cable", non_empty=True)
self.assertEqual([c["value"] for c in r["conductors"]], ["C1"])
# non_empty alone, with no attribute, filters nothing
self.assertEqual(m.tool_conductors(self.qet, non_empty=True)["count"], 3)
self.assertNotIn("value", m.tool_conductors(self.qet)["conductors"][0])
r = m.tool_conductors(self.qet, limit=2)
self.assertEqual((r["count"], r["truncated"], len(r["conductors"])), (3, True, 2))
r = m.tool_conductors(self.qet, limit=3)
self.assertEqual((r["count"], r["truncated"], len(r["conductors"])), (3, False, 3))
def test_default_limit_is_200_rows(self):
els = "".join(f'' for i in range(201))
wires = "".join(f'' for i in range(201))
big = Path(self.tmp.name) / "big.qet"
big.write_text(f'{els}'
f'{wires}')
for tool, key in ((m.tool_elements, "elements"), (m.tool_conductors, "conductors")):
with self.subTest(tool=key):
r = tool(str(big))
self.assertEqual((r["count"], r["truncated"], len(r[key])), (201, True, 200))
def test_conductor_key_same_in_both_forms(self):
# A wire in the numbered form before a save and the uuid form after
# it (the first save of an older project) is the same wire: both
# ends resolve to the placed symbol's terminal record. The record is
# where the wire docks, 4 from the definition position.
root = ET.fromstring(
''
''
''
''
''
''
''
''
''
''
''
''
'')
numbered, by_uuid = [m._conductor_row(i, c, ix)["key"]
for i, c, ix in m._conductors(root)]
self.assertEqual(numbered, "1:{E}@0,-4,2--{E}@6,0,1")
self.assertEqual(by_uuid, numbered)
def test_conductor_key_same_in_both_forms_in_a_column(self):
"""Terminals one above the other share their x: a uuid end must be
matched on x, y and orientation together, not on any one of them.
Found by the mutation audit: an "or" in place of "and" went
unnoticed while every test's terminals differed in every coordinate."""
root = ET.fromstring(
''
''
''
''
''
''
''
''
''
''
''
''
''
''
'')
numbered, by_uuid = [m._conductor_row(i, c, ix)["key"]
for i, c, ix in m._conductors(root)]
self.assertEqual(numbered, "1:{E}@0,24,0--{E}@30,24,0")
self.assertEqual(by_uuid, numbered)
def test_conductor_row_without_an_index(self):
c = ET.fromstring('')
self.assertEqual(m._conductor_row(3, c)["key"], "3:#7--#8")
def test_plain_text(self):
self.assertEqual(m._plain_text("
a & b
\n
c
"),
"a & b c")
# no : the whole string is the text
self.assertEqual(m._plain_text("plain x"), "plain x")
self.assertEqual(m._plain_text(None), "")
class ItemsTool(unittest.TestCase):
"""qet_items: every drawn item that is not a symbol or a wire, with its uuid."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.qet = str(Path(self.tmp.name) / "p.qet")
Path(self.qet).write_text(
''
''
'K1label'
''
''
''
''
''
''
''
'')
def tearDown(self):
self.tmp.cleanup()
def test_every_kind_with_its_uuid(self):
r = m.tool_items(self.qet)
self.assertEqual((r["count"], r["truncated"]), (5, False))
self.assertEqual(r["items"], [
{"kind": "text", "uuid": "{tx}", "folio": 1, "x": "1", "y": "2", "text": "note",
"rotation": "0", "font": "f", "color": "c"},
{"kind": "shape", "uuid": "{sh}", "folio": 1, "type": "Line", "from": ["0", "0"],
"to": ["9", "9"], "line_color": "c", "line_style": "s", "line_width": "1",
"fill": "none", "rotation": "0"},
{"kind": "element_text", "uuid": "{et}", "folio": 1, "element": "{el}",
"source": "ElementInfo", "bound_to": "label", "n": 1, "x": "5", "y": "6",
"size": "9", "frame": "false", "rotation": "0", "width": "-1", "shows": "K1"},
# saved before pictures carried a uuid: "" until the next save
{"kind": "image", "uuid": "", "folio": 2, "x": "3", "y": "4", "scale": "1",
"rotation": "0"},
{"kind": "table", "uuid": "{tb}", "folio": 2, "name": "Parts", "x": "0", "y": "0",
"width": "100", "height": "50", "rows_shown": "10"}])
def test_filters_and_limit(self):
kinds = lambda r: [i["kind"] for i in r["items"]]
self.assertEqual(kinds(m.tool_items(self.qet, folio=2)), ["image", "table"])
self.assertEqual(kinds(m.tool_items(self.qet, kind="shape")), ["shape"])
self.assertEqual(kinds(m.tool_items(self.qet, folio=2, kind="shape")), [])
r = m.tool_items(self.qet, limit=2)
self.assertEqual((r["count"], r["truncated"], len(r["items"])), (5, True, 2))
r = m.tool_items(self.qet, limit=5)
self.assertEqual((r["count"], r["truncated"]), (5, False))
with self.assertRaisesRegex(ValueError, "kind must be one of"):
m.tool_items(self.qet, kind="wire")
def test_default_limit_is_500(self):
many = "".join(f'' for i in range(501))
big = Path(self.tmp.name) / "big.qet"
big.write_text(f'{many}')
r = m.tool_items(str(big))
self.assertEqual((r["count"], r["truncated"], len(r["items"])), (501, True, 500))
class DiffContracts(unittest.TestCase):
"""Exact qet_diff output, section by section, on hand-made pairs.
The mutation audit found most of tool_diff's output unchecked: a dropped
field of a move, of a conductor change or of a text record, a skipped
comparison, or a list cap off by one all passed the suite."""
A = "{aaaaaaaa-0000-4000-8000-000000000001}"
B = "{bbbbbbbb-0000-4000-8000-000000000002}"
C = "{cccccccc-0000-4000-8000-000000000003}"
D = "{dddddddd-0000-4000-8000-000000000004}"
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.n = 0
def tearDown(self):
self.tmp.cleanup()
def qet(self, body, title="T"):
self.n += 1
p = Path(self.tmp.name) / f"p{self.n}.qet"
p.write_text(f'{body}')
return str(p)
@staticmethod
def el(uuid, x, y, label=None, comment=None, type_="x/coil.elmt", texts=""):
info = "".join(f'{v}'
for n, v in (("label", label), ("comment", comment)) if v is not None)
u = f' uuid="{uuid}"' if uuid else ""
return (f''
f'{info}'
f'{texts}')
@staticmethod
def wire(e1, e2, **attrs):
a = "".join(f' {k}="{v}"' for k, v in attrs.items())
return f''
def folio(self, elements="", wires="", extra="", **attrs):
a = "".join(f' {k}="{v}"' for k, v in attrs.items())
return (f'{elements}'
f'{wires}{extra}')
def test_elements_section_exact(self):
A, B, C, D = self.A, self.B, self.C, self.D
before = self.qet(self.folio(
self.el(A, 10, 20, "K1", "c") + self.el(B, 0, 0) + self.el(C, 5, 5)
+ self.el("", 1, 1, type_="x/old.elmt")))
after = self.qet(self.folio(
self.el(A, 15, 20, "K2", "d") + self.el(B, 0, 0) + self.el(D, 7, 7)
+ self.el("", 2, 2, type_="x/old.elmt")))
self.assertEqual(m.tool_diff(before, after)["elements"], {
"before": 4, "after": 4,
# no uuid: keyed on folio, position and name, so a move is remove + add
"added": sorted([D, "1:2,2:old"]), "removed": sorted([C, "1:1,1:old"]),
"moved": [{"uuid": A, "name": "coil", "folio": 1, "from": ["10", "20"],
"to": ["15", "20"], "delta": [5.0, 0.0]}],
"moved_count": 1, "distinct_move_deltas": [[5.0, 0.0]],
"relabelled": [{"uuid": A, "name": "coil", "from": "K1", "to": "K2"}],
"info_changed": [{"uuid": A, "name": "coil", "from": {"label": "K1", "comment": "c"},
"to": {"label": "K2", "comment": "d"}}],
"rotated": []})
def test_an_unchanged_element_reports_nothing(self):
p = self.qet(self.folio(self.el(self.A, 1, 2, "K1", "c")))
e = m.tool_diff(p, p)["elements"]
self.assertEqual((e["moved"], e["relabelled"], e["info_changed"], e["added"], e["removed"],
e["rotated"]), ([], [], [], [], [], []))
def test_equal_angles_written_differently_are_the_same(self):
self.assertEqual([m._angle(v) for v in ("-270", "90", "-90", "270", "360", "0", "450", "12.5", "", "x")],
["90", "90", "270", "270", "0", "0", "90", "12.5", "", "x"])
def field(rot):
return self.el(self.A, 0, 0, texts=(
f't'
''))
d = m.tool_diff(self.qet(self.folio(field("90"))), self.qet(self.folio(field("-270"))))
self.assertEqual(d["element_texts"]["changed"], [])
d = m.tool_diff(self.qet(self.folio(field("90"))), self.qet(self.folio(field("180"))))
self.assertEqual(d["element_texts"]["changed"][0]["changed"], {"rotation": ["90", "180"]})
def test_a_rotation_is_reported(self):
"""Rotating a symbol changes only its orientation (quarter turns); a diff
that ignored it reported a rotation as no change at all."""
rot = lambda o: self.qet(self.folio(
self.el(self.A, 1, 2).replace('x="1"', f'orientation="{o}" x="1"')))
d = m.tool_diff(rot(0), rot(1))
self.assertEqual(d["elements"]["rotated"],
[{"uuid": self.A, "name": "coil", "folio": 1, "orientation": ["0", "1"]}])
self.assertEqual((d["elements"]["moved"], d["elements"]["relabelled"]), ([], []))
# no attribute is orientation 0, as QElectroTech reads it
plain = self.qet(self.folio(self.el(self.A, 1, 2)))
self.assertEqual(m.tool_diff(plain, rot(0))["elements"]["rotated"], [])
def test_conductors_section_exact(self):
A, B, C = self.A, self.B, self.C
els = self.el(A, 0, 0) + self.el(B, 0, 0) + self.el(C, 0, 0)
before = self.qet(self.folio(els, self.wire(A, B, num="W1", cable="C1")
+ self.wire(B, C, num="W2")))
after = self.qet(self.folio(els, self.wire(A, B, num="W9", cable="C1", conductor_color="red")
+ self.wire(A, C, num="W3")))
k = lambda x, y: f"1:{x}/{{t1}}--{y}/{{t2}}"
self.assertEqual(m.tool_diff(before, after)["conductors"], {
"before": 2, "after": 2, "keyed_by": "ends", "added": [k(A, C)], "removed": [k(B, C)],
"changed": [{"key": k(A, B), "changed": {"num": ["W1", "W9"], "color": ["", "red"]}}],
"changed_count": 1})
def test_unresolvable_conductor_ends_are_flagged(self):
p = self.qet(self.folio(self.el("", 0, 0),
''))
c = m.tool_diff(p, p)["conductors"]
self.assertEqual(c["unstable_keys"], 1)
self.assertIn("renumbers on save", c["warning"])
q = self.qet(self.folio(self.el(self.A, 0, 0) + self.el(self.B, 0, 0),
self.wire(self.A, self.B)))
self.assertNotIn("unstable_keys", m.tool_diff(q, q)["conductors"])
def test_list_caps(self):
many = lambda n, x: "".join(self.el(f"{{{i:08d}-0000-4000-8000-000000000000}}", x, 0)
for i in range(n))
e = m.tool_diff(self.qet(self.folio(many(101, 0))), self.qet(self.folio(many(101, 5))))["elements"]
self.assertEqual((len(e["moved"]), e["moved_count"]), (100, 101))
e = m.tool_diff(self.qet(self.folio(many(51, 0))), self.qet(self.folio("")))["elements"]
self.assertEqual((len(e["removed"]), e["before"]), (50, 51))
e = m.tool_diff(self.qet(self.folio("")), self.qet(self.folio(many(51, 0))))["elements"]
self.assertEqual(len(e["added"]), 50)
def test_texts_shapes_images_by_uuid(self):
def extras(x, color, uuid=True):
u = lambda s: f' uuid="{{{s}}}"' if uuid else ""
return (f''
f''
f''
f'')
d = m.tool_diff(self.qet(self.folio(extra=extras(1, "#000"))),
self.qet(self.folio(extra=extras(4, "#f00"))))
# A uuid-keyed item is labelled as it is after the edit; "changed" has both.
self.assertEqual(d["texts"]["changed"], [{
"item": {"folio": 1, "x": "4", "y": "2", "text": "note", "uuid": "{t}"},
"changed": {"x": ["1", "4"], "color": ["#000", "#f00"]}}])
self.assertEqual(d["shapes"]["changed"], [{
"item": {"folio": 1, "type": "Rectangle", "from": ["4", "0"], "to": ["9", "9"], "uuid": "{s}"},
"changed": {"from": [["1", "0"], ["4", "0"]], "line_color": ["#000", "#f00"]}}])
self.assertEqual(d["images"]["changed"], [{
"item": {"folio": 1, "x": "4", "y": "3", "uuid": "{i}"}, "changed": {"x": ["1", "4"]}}])
for kind in ("texts", "shapes", "images"):
self.assertEqual((d[kind]["keyed_by"], d[kind]["before"], d[kind]["after"]), ("uuid", 1, 1))
# without uuids a move is a removal plus an addition, keyed by position
d = m.tool_diff(self.qet(self.folio(extra=extras(1, "#000", uuid=False))),
self.qet(self.folio(extra=extras(4, "#000", uuid=False))))
self.assertEqual((d["images"]["keyed_by"], len(d["images"]["added"]), len(d["images"]["removed"])),
("position", 1, 1))
def test_shape_style_fields(self):
def shape(style, width, brush):
return (f''
f'{brush}')
d = m.tool_diff(
self.qet(self.folio(extra=shape("SolidLine", 1, ''))),
self.qet(self.folio(extra=shape("DashLine", 2, ''))))
self.assertEqual(d["shapes"]["changed"][0]["changed"],
{"line_style": ["SolidLine", "DashLine"], "line_width": ["1", "2"],
"fill": ["none", "#0f0"]})
def test_element_text_fields(self):
def field(x, size, shows):
return (f'{shows}'
'label')
d = m.tool_diff(self.qet(self.folio(self.el(self.A, 0, 0, texts=field(3, 9, "K1")))),
self.qet(self.folio(self.el(self.A, 0, 0, texts=field(6, 9, "K2")))))
self.assertEqual(d["element_texts"]["changed"], [{
"item": {"element": self.A, "source": "ElementInfo", "bound_to": "label", "n": 1},
"changed": {"x": ["3", "6"], "shows": ["K1", "K2"]}}])
def test_folio_and_project_fields(self):
d = m.tool_diff(self.qet(self.folio(title="A", cols="10"), title="P"),
self.qet(self.folio(title="B", cols="10"), title="Q"))
self.assertEqual(d["project"], {"changed": {"title": ["P", "Q"]}})
self.assertEqual(d["folios"], {"before": 1, "after": 1, "keyed_by": "position",
"changed": [{"folio": 1, "changed": {"title": ["A", "B"]}}]})
d = m.tool_diff(self.qet(self.folio(title="A")),
self.qet(self.folio(title="B") + self.folio(title="C")))
self.assertIn("folio count changed", d["folios"]["note"])
d = m.tool_diff(self.qet(self.folio(title="A")),
self.qet(self.folio(title="A") + self.folio(title="C")))
self.assertNotIn("note", d["folios"])
def test_every_compared_field_is_reported_on_its_own(self):
"""Change one attribute at a time; each must show as exactly that field."""
def changed_fields(before, after, kind):
d = m.tool_diff(self.qet(self.folio(extra=before)), self.qet(self.folio(extra=after)))
return [list(c["changed"]) for c in d[kind]["changed"]]
def text(**v):
a = {"rotation": "0", "font": "f", "color": "c", **v}
return ('')
def shape(rot):
return (f'')
def image(**v):
a = {"size": "1", "rotation": "0", **v}
return ('')
for kind, before, after, field in (
("texts", text(), text(rotation="90"), "rotation"),
("texts", text(), text(font="g"), "font"),
("shapes", shape(0), shape(90), "rotation"),
("images", image(), image(rotation="90"), "rotation"),
("images", image(), image(size="2"), "scale")):
with self.subTest(kind=kind, field=field):
self.assertEqual(changed_fields(before, after, kind), [[field]])
def test_every_element_text_field_attribute(self):
def field(**v):
a = {"x": "0", "y": "0", "frame": "false", "rotation": "0", "text_width": "-1",
"font": "Sans,9,-1", **v}
return self.el(self.A, 0, 0, texts=(
"K1'
'%{label}'))
for attr, value, reported in (("y", "5", "y"), ("rotation", "90", "rotation"),
("text_width", "40", "width"), ("frame", "true", "frame"),
("font", "Sans,12,-1", "size")):
with self.subTest(attr=attr):
d = m.tool_diff(self.qet(self.folio(field())),
self.qet(self.folio(field(**{attr: value}))))
ch = d["element_texts"]["changed"]
self.assertEqual([list(c["changed"]) for c in ch], [[reported]])
# a composite field is identified by its formula
self.assertEqual(ch[0]["item"]["bound_to"], "%{label}")
def test_an_unchanged_conductor_is_not_listed_as_changed(self):
A, B, C = self.A, self.B, self.C
els = self.el(A, 0, 0) + self.el(B, 0, 0) + self.el(C, 0, 0)
before = self.qet(self.folio(els, self.wire(A, B, num="W1") + self.wire(B, C, num="W2")))
after = self.qet(self.folio(els, self.wire(A, B, num="W1") + self.wire(B, C, num="W5")))
c = m.tool_diff(before, after)["conductors"]
self.assertEqual([x["changed"] for x in c["changed"]], [{"num": ["W2", "W5"]}])
def test_a_strip_without_its_data_is_skipped(self):
p = self.qet(self.folio() + '')
self.assertEqual(m.tool_diff(p, p)["terminal_strips"]["before"], 0)
def test_every_list_is_capped(self):
U = lambda i: f"{{{i:08d}-0000-4000-8000-000000000000}}"
many = lambda n, label: "".join(self.el(U(i), 0, 0, label) for i in range(n))
e = m.tool_diff(self.qet(self.folio(many(51, "a"))),
self.qet(self.folio(many(51, "b"))))["elements"]
self.assertEqual((len(e["relabelled"]), len(e["info_changed"])), (50, 50))
turned = lambda o: "".join(self.el(U(i), 0, 0).replace('x="0"', f'orientation="{o}" x="0"')
for i in range(51))
e = m.tool_diff(self.qet(self.folio(turned(0))), self.qet(self.folio(turned(2))))["elements"]
self.assertEqual(len(e["rotated"]), 50)
els = many(102, None)
wires = lambda n, num: "".join(self.wire(U(i), U(i + 1), num=num) for i in range(n))
c = m.tool_diff(self.qet(self.folio(els, wires(101, "a"))),
self.qet(self.folio(els, wires(101, "b"))))["conductors"]
self.assertEqual((len(c["changed"]), c["changed_count"]), (100, 101))
c = m.tool_diff(self.qet(self.folio(els, wires(51, "a"))), self.qet(self.folio(els)))["conductors"]
self.assertEqual(len(c["removed"]), 50)
c = m.tool_diff(self.qet(self.folio(els)), self.qet(self.folio(els, wires(51, "a"))))["conductors"]
self.assertEqual(len(c["added"]), 50)
folios = lambda t: "".join(self.folio(title=f"{t}{i}") for i in range(51))
f = m.tool_diff(self.qet(folios("a")), self.qet(folios("b")))["folios"]
self.assertEqual(len(f["changed"]), 50)
# _diff_keyed's own caps, through the text records
texts = lambda n, x: "" + "".join(
f'' for i in range(n)) + ""
t = m.tool_diff(self.qet(self.folio(extra=texts(51, 0))),
self.qet(self.folio(extra=texts(51, 1))))["texts"]
self.assertEqual(len(t["changed"]), 50)
t = m.tool_diff(self.qet(self.folio(extra=texts(51, 0))), self.qet(self.folio()))["texts"]
self.assertEqual(len(t["removed"]), 50)
t = m.tool_diff(self.qet(self.folio()), self.qet(self.folio(extra=texts(51, 0))))["texts"]
self.assertEqual(len(t["added"]), 50)
def test_conductors_by_uuid(self):
"""Every conductor has a uuid: a rewire is that conductor with new ends,
not one removed and another added."""
A, B, C = self.A, self.B, self.C
els = self.el(A, 0, 0) + self.el(B, 0, 0) + self.el(C, 0, 0)
before = self.qet(self.folio(els, self.wire(A, B, uuid="{w1}", num="W1")
+ self.wire(B, C, uuid="{w2}")))
after = self.qet(self.folio(els, self.wire(A, C, uuid="{w1}", num="W1")
+ self.wire(A, B, uuid="{w3}")))
k = lambda x, y: f"1:{x}/{{t1}}--{y}/{{t2}}"
c = m.tool_diff(before, after)["conductors"]
self.assertEqual(c["keyed_by"], "uuid")
self.assertEqual(c["changed"], [{"key": k(A, C), "uuid": "{w1}",
"changed": {"ends": [k(A, B), k(A, C)]}}])
# added/removed are still named by their ends, as in the ends mode
self.assertEqual((c["added"], c["removed"]), ([k(A, B)], [k(B, C)]))
# one conductor without a uuid puts the whole comparison back on ends
mixed = self.qet(self.folio(els, self.wire(A, B, uuid="{w1}", num="W1") + self.wire(B, C)))
self.assertEqual(m.tool_diff(before, mixed)["conductors"]["keyed_by"], "ends")
def test_folios_by_uuid(self):
"""With folio uuids a reorder is one 'reordered' entry, not every later
folio changing title."""
f = lambda u, t: self.folio(title=t, uuid=u)
before = self.qet(f("{f1}", "One") + f("{f2}", "Two") + f("{f3}", "Three"))
after = self.qet(f("{f2}", "Two") + f("{f1}", "One") + f("{f4}", "Four"))
d = m.tool_diff(before, after)["folios"]
self.assertEqual(d["keyed_by"], "uuid")
self.assertEqual(d["changed"], [])
self.assertEqual(d["reordered"], [{"uuid": "{f2}", "title": "Two", "from": 2, "to": 1},
{"uuid": "{f1}", "title": "One", "from": 1, "to": 2}])
self.assertEqual(d["added"], [{"folio": 3, "uuid": "{f4}", "title": "Four"}])
self.assertEqual(d["removed"], [{"folio": 3, "uuid": "{f3}", "title": "Three"}])
self.assertNotIn("note", d)
renamed = self.qet(f("{f1}", "Uno") + f("{f2}", "Two") + f("{f3}", "Three"))
self.assertEqual(m.tool_diff(before, renamed)["folios"]["changed"],
[{"folio": 1, "uuid": "{f1}", "changed": {"title": ["One", "Uno"]}}])
def test_element_text_fields_by_uuid(self):
"""By their own uuid, deleting the first of two label fields is that
field removed -- by position it read as the second one changing."""
def fields(*items):
return self.el(self.A, 0, 0, texts="".join(
f'{t}'
'' for u, x, t in items))
before = self.qet(self.folio(fields(("{e1}", 1, "a"), ("{e2}", 2, "b"))))
after = self.qet(self.folio(fields(("{e2}", 2, "b"))))
d = m.tool_diff(before, after)["element_texts"]
self.assertEqual(d["keyed_by"], "uuid")
self.assertEqual((d["changed"], [r["uuid"] for r in d["removed"]]), ([], ["{e1}"]))
# the same pair without uuids: the position-based reading, flagged as such
plain = lambda *items: self.qet(self.folio(self.el(self.A, 0, 0, texts="".join(
f'{t}'
for x, t in items))))
d = m.tool_diff(plain((1, "a"), (2, "b")), plain((2, "b")))["element_texts"]
self.assertEqual(d["keyed_by"], "position")
def test_copied_symbols_keep_their_text_field_uuids(self):
"""Copying a symbol keeps its text fields' uuids (20 copies of one in
2612_ats_singlephase.qet), so a field is its symbol's uuid plus its own:
editing one copy's field must not be read as another copy's."""
field = lambda x: ('t')
pair = lambda xa, xb: self.qet(self.folio(self.el(self.A, 0, 0, texts=field(xa))
+ self.el(self.B, 0, 0, texts=field(xb))))
for before, after, which in ((pair(1, 1), pair(5, 1), self.A), (pair(1, 1), pair(1, 5), self.B)):
with self.subTest(edited=which):
d = m.tool_diff(before, after)["element_texts"]
self.assertEqual(d["keyed_by"], "uuid")
self.assertEqual([(c["item"]["element"], c["item"]["uuid"], c["changed"])
for c in d["changed"]], [(which, "{same}", {"x": ["1", "5"]})])
def test_a_field_on_a_symbol_without_a_uuid_is_matched_by_position(self):
"""The field has a uuid but its symbol has none (an older file): with no
symbol to pair it with, the whole kind falls back to position."""
field = lambda x: ('t')
on = lambda sym, x: self.qet(self.folio(self.el(sym, 0, 0, texts=field(x))))
d = m.tool_diff(on("", 1), on("", 2))["element_texts"]
self.assertEqual(d["keyed_by"], "position")
self.assertEqual([c["changed"] for c in d["changed"]], [{"x": ["1", "2"]}])
# the symbol has a uuid on one side only: each side is checked on its own
for before, after in ((on("", 1), on(self.A, 1)), (on(self.A, 1), on("", 1))):
with self.subTest(before=before, after=after):
self.assertEqual(m.tool_diff(before, after)["element_texts"]["keyed_by"], "position")
def test_repeated_uuids_fall_back_rather_than_merge(self):
# the same field uuid twice inside one symbol
twice = lambda x: self.qet(self.folio(self.el(self.A, 0, 0, texts="".join(
f'{v}'
'' for v in (x, 9)))))
self.assertEqual(m.tool_diff(twice(1), twice(2))["element_texts"]["keyed_by"], "position")
# two shapes sharing a uuid: both must still be counted
shapes = self.qet(self.folio(extra='' + ''.join(
f'' for i in (0, 5)) + ''))
d = m.tool_diff(shapes, shapes)["shapes"]
self.assertEqual((d["keyed_by"], d["before"]), ("position", 2))
# two conductors sharing a uuid
els = self.el(self.A, 0, 0) + self.el(self.B, 0, 0) + self.el(self.C, 0, 0)
wires = self.qet(self.folio(els, self.wire(self.A, self.B, uuid="{w}")
+ self.wire(self.B, self.C, uuid="{w}")))
c = m.tool_diff(wires, wires)["conductors"]
self.assertEqual((c["keyed_by"], c["before"]), ("ends", 2))
def test_uuid_matching_when_one_side_has_none_of_a_kind(self):
"""No folios, fields or wires on one side is not a reason to fall back
to position: the other side's uuids are all there is to match."""
empty = self.qet("")
full = self.qet(self.folio(
self.el(self.A, 0, 0, texts='t')
+ self.el(self.B, 0, 0), self.wire(self.A, self.B, uuid="{w1}"), uuid="{f1}"))
d = m.tool_diff(empty, full)
self.assertEqual((d["folios"]["keyed_by"], d["element_texts"]["keyed_by"],
d["conductors"]["keyed_by"]), ("uuid", "uuid", "uuid"))
self.assertEqual(d["folios"]["added"], [{"folio": 1, "uuid": "{f1}", "title": ""}])
# matched by uuid, a conductor is never on the renumbered-id footing
self.assertNotIn("unstable_keys", d["conductors"])
def test_a_folio_that_kept_its_place_is_not_reordered(self):
f = lambda u, t: self.folio(title=t, uuid=u)
before = self.qet(f("{f1}", "One") + f("{f2}", "Two") + f("{f3}", "Three"))
after = self.qet(f("{f1}", "One") + f("{f3}", "Three") + f("{f2}", "Two"))
self.assertEqual([r["uuid"] for r in m.tool_diff(before, after)["folios"]["reordered"]],
["{f3}", "{f2}"])
def test_folio_lists_are_capped(self):
f = lambda i, t: self.folio(title=t, uuid=f"{{{i:04d}}}")
many = lambda rng, t: "".join(f(i, t) for i in rng)
d = m.tool_diff(self.qet(""), self.qet(many(range(51), "x")))["folios"]
self.assertEqual(len(d["added"]), 50)
d = m.tool_diff(self.qet(many(range(51), "x")), self.qet(""))["folios"]
self.assertEqual(len(d["removed"]), 50)
d = m.tool_diff(self.qet(many(range(52), "x")), self.qet(many(reversed(range(52)), "x")))["folios"]
self.assertEqual(len(d["reordered"]), 50)
def test_table_fields(self):
def table(**v):
a = {"x": "0", "y": "0", "width": "100", "height": "50", "display_n_row": "10", **v}
return ('')
for attr, reported in (("y", "y"), ("width", "width"), ("height", "height")):
with self.subTest(attr=attr):
d = m.tool_diff(self.qet(self.folio(extra=table())),
self.qet(self.folio(extra=table(**{attr: "7"}))))["tables"]
self.assertEqual([list(c["changed"]) for c in d["changed"]], [[reported]])
def test_tables(self):
table = lambda x, rows: ('')
d = m.tool_diff(self.qet(self.folio(extra=table(0, 10))),
self.qet(self.folio(extra=table(20, 12))))["tables"]
self.assertEqual((d["keyed_by"], d["before"], d["after"]), ("uuid", 1, 1))
self.assertEqual(d["changed"], [{"item": {"folio": 1, "name": "Parts", "uuid": "{tb}"},
"changed": {"x": ["0", "20"], "rows_shown": ["10", "12"]}}])
def test_terminal_strips(self):
def strip(name, n):
terms = "".join(f'' for _ in range(n))
return (''
'I'
'L'
f'{name}'
f'{terms}')
d = m.tool_diff(self.qet(self.folio() + strip("X1", 2)), self.qet(self.folio() + strip("X1", 3)))
self.assertEqual(d["terminal_strips"]["changed"],
[{"item": "I L X1", "changed": {"terminals": [2, 3]}}])
class Diff(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = Path(self.tmp.name)
def tearDown(self):
self.tmp.cleanup()
def project(self, name, *, texts=(), shapes=(), images=(), author="", version="1",
strips=(), conductors=()):
# An optional last member of each tuple is the item's uuid.
uid = lambda u: f' uuid="{u[0]}"' if u else ""
inputs = "".join(f''
for x, y, t, *u in texts)
shp = "".join(f''
f''
f''
for a, b, c, d, pc, *u in shapes)
img = "".join(f''
for x, y, s, *u in images)
st = "".join(f''
f'{n}'
f'' for u, n in strips)
cond = "".join(f''
for a, b, n in conductors)
xml = (f''
f'{cond}{inputs}'
f'{img}{shp}'
f'{st}')
p = self.dir / name
p.write_text(xml)
return str(p)
def test_identical_projects_have_an_empty_diff_everywhere(self):
a = self.project("a.qet", texts=[(1, 2, "hi")], shapes=[(0, 0, 5, 5, "#000")],
images=[(3, 3, 1)], strips=[("{u}", "X1")])
d = m.tool_diff(a, a)
for k in ("texts", "shapes", "images", "terminal_strips"):
with self.subTest(section=k):
self.assertFalse(d[k]["added"] or d[k]["removed"] or d[k]["changed"])
self.assertEqual(d["folios"]["changed"], [])
def test_a_symbols_own_texts_are_not_free_texts(self):
"""Symbols in older files carry their own texts;
counting them made schema_indus.qet show 124 free texts where
QElectroTech has 2."""
d = self.dir
a, b = d / "sym_a.qet", d / "sym_b.qet"
body = (''
''
''
'')
a.write_text(body.replace("{t}", "inside"))
b.write_text(body.replace("{t}", "changed inside"))
t = m.tool_diff(str(a), str(b))["texts"]
self.assertEqual((t["before"], t["after"], t["added"], t["removed"], t["changed"]),
(1, 1, [], [], []))
def test_edited_text_reads_as_removed_plus_added(self):
a = self.project("a.qet", texts=[(1, 2, "note")])
b = self.project("b.qet", texts=[(1, 2, "note EDITED")])
d = m.tool_diff(a, b)["texts"]
self.assertEqual([t["text"] for t in d["removed"]], ["note"])
self.assertEqual([t["text"] for t in d["added"]], ["note EDITED"])
U1 = "{11111111-1111-4111-8111-111111111111}"
U2 = "{22222222-2222-4222-8222-222222222222}"
def test_edited_text_with_a_uuid_is_a_change_to_that_text(self):
a = self.project("a.qet", texts=[(1, 2, "note", self.U1)])
b = self.project("b.qet", texts=[(1, 2, "note EDITED", self.U1)])
d = m.tool_diff(a, b)["texts"]
self.assertEqual(d["keyed_by"], "uuid")
self.assertEqual((d["added"], d["removed"]), ([], []))
self.assertEqual(d["changed"][0]["changed"]["text"], ["note", "note EDITED"])
def test_moved_shape_with_a_uuid_is_a_change_to_that_shape(self):
a = self.project("a.qet", shapes=[(0, 0, 5, 5, "#000000", self.U1),
(9, 9, 12, 12, "#000000", self.U2)])
b = self.project("b.qet", shapes=[(100, 0, 105, 5, "#000000", self.U1),
(9, 9, 12, 12, "#000000", self.U2)])
d = m.tool_diff(a, b)["shapes"]
self.assertEqual((d["added"], d["removed"]), ([], []))
self.assertEqual(len(d["changed"]), 1)
self.assertEqual(d["changed"][0]["item"]["uuid"], self.U1)
self.assertEqual(d["changed"][0]["changed"]["from"], [["0", "0"], ["100", "0"]])
def test_a_file_without_uuids_against_its_resave_is_keyed_by_position(self):
"""The first save by a current QElectroTech adds uuids to a legacy
file: that must not read as every item removed and re-added."""
a = self.project("a.qet", texts=[(1, 2, "note")], images=[(3, 3, 1)])
b = self.project("b.qet", texts=[(1, 2, "note", self.U1)], images=[(3, 3, 1, self.U2)])
d = m.tool_diff(a, b)
for k in ("texts", "images"):
with self.subTest(section=k):
self.assertEqual(d[k]["keyed_by"], "position")
self.assertFalse(d[k]["added"] or d[k]["removed"] or d[k]["changed"])
def test_shape_restyle_is_a_change_to_that_item(self):
a = self.project("a.qet", shapes=[(0, 0, 5, 5, "#000000")])
b = self.project("b.qet", shapes=[(0, 0, 5, 5, "#ff0000")])
d = m.tool_diff(a, b)["shapes"]
self.assertEqual(d["added"], [])
self.assertEqual(d["changed"][0]["changed"]["line_color"], ["#000000", "#ff0000"])
def test_image_rescale_is_a_change(self):
a = self.project("a.qet", images=[(1, 1, 2)])
b = self.project("b.qet", images=[(1, 1, 3)])
self.assertEqual(m.tool_diff(a, b)["images"]["changed"][0]["changed"]["scale"], ["2", "3"])
def test_folio_author_change(self):
a = self.project("a.qet", author="A")
b = self.project("b.qet", author="B")
self.assertEqual(m.tool_diff(a, b)["folios"]["changed"][0]["changed"]["author"], ["A", "B"])
def test_the_file_version_stamp_is_not_a_change(self):
"""QElectroTech rewrites every folio's version attribute on save;
comparing it made every folio of a re-saved project look edited."""
a = self.project("a.qet", version="0.80c")
b = self.project("b.qet", version="0.200.1-dev")
self.assertEqual(m.tool_diff(a, b)["folios"]["changed"], [])
def test_empty_information_and_a_missing_one_are_the_same(self):
"""QElectroTech writes an empty label as
and drops it on the next save, so comparing the raw bags made every
unlabelled element of a re-saved project look edited."""
def proj(infos):
return (f''
f'{infos}'
f'')
a = self.dir / "a.qet"
b = self.dir / "b.qet"
c = self.dir / "c.qet"
a.write_text(proj(''
''))
b.write_text(proj(''))
c.write_text(proj('K1'))
self.assertEqual(m.tool_diff(str(a), str(b))["elements"]["info_changed"], [])
# A value that really appears or disappears is still reported.
changed = m.tool_diff(str(b), str(c))["elements"]["info_changed"]
self.assertEqual([(x["from"], x["to"]) for x in changed], [({}, {"label": "K1"})])
def test_element_text_field_moves_and_restyles_are_reported(self):
def proj(x, size, frame, extra=""):
return (f''
f'KM1'
f'label{extra}'
f'')
a = self.dir / "a.qet"
b = self.dir / "b.qet"
a.write_text(proj(30, 9, "false"))
b.write_text(proj(60, 14, "true", 'NEW'
''))
d = m.tool_diff(str(a), str(b))["element_texts"]
self.assertEqual(len(d["added"]), 1)
self.assertEqual(d["changed"][0]["changed"],
{"x": ["30", "60"], "size": ["9", "14"], "frame": ["false", "true"]})
self.assertEqual(d["changed"][0]["item"]["bound_to"], "label")
self.assertFalse(m.tool_diff(str(a), str(a))["element_texts"]["changed"])
def test_project_title_and_folio_frame_changes_are_reported(self):
def proj(title, cols):
return (f'')
a, b = self.dir / "a.qet", self.dir / "b.qet"
a.write_text(proj("Old", 17))
b.write_text(proj("New", 10))
d = m.tool_diff(str(a), str(b))
self.assertEqual(d["project"]["changed"], {"title": ["Old", "New"]})
self.assertEqual(d["folios"]["changed"][0]["changed"], {"cols": ["17", "10"]})
self.assertEqual(m.tool_diff(str(a), str(a))["project"]["changed"], {})
def test_strip_added(self):
a = self.project("a.qet")
b = self.project("b.qet", strips=[("{u}", "X9")])
self.assertEqual(m.tool_diff(a, b)["terminal_strips"]["added"], ["X9"])
def test_text_html_is_reduced_to_visible_text(self):
self.assertEqual(m._plain_text("
a & b
"), "a & b")
class ConductorKey(unittest.TestCase):
"""The key must survive a save. QElectroTech renumbers the folio-scoped
terminal ids on every write, so keying on the raw pair made every
conductor of an untouched folio read as removed and re-added."""
def diagram(self, terminals, conductors):
d = ET.Element("diagram")
for uuid, tid, x, y in terminals:
el = ET.SubElement(d, "element", {"uuid": uuid})
ET.SubElement(el, "terminal", {"id": tid, "x": str(x), "y": str(y), "orientation": "n"})
for a, b in conductors:
ET.SubElement(d, "conductor", {"terminal1": a, "terminal2": b})
return d
def keys(self, d):
ix = m._terminal_index(d)
return {m._conductor_key(1, c, ix) for c in d.iter("conductor")}
def test_renumbering_the_ids_does_not_change_the_key(self):
before = self.diagram([("{a}", "1", 0, 0), ("{b}", "16", 0, 0)], [("1", "16")])
after = self.diagram([("{a}", "34", 0, 0), ("{b}", "15", 0, 0)], [("34", "15")])
self.assertEqual(self.keys(before), self.keys(after))
def test_end_order_does_not_matter(self):
one = self.diagram([("{a}", "1", 0, 0), ("{b}", "2", 0, 0)], [("1", "2")])
two = self.diagram([("{a}", "1", 0, 0), ("{b}", "2", 0, 0)], [("2", "1")])
self.assertEqual(self.keys(one), self.keys(two))
def test_different_conductors_get_different_keys(self):
d = self.diagram([("{a}", "1", 0, 0), ("{b}", "2", 0, 0), ("{c}", "3", 0, 0)],
[("1", "2"), ("1", "3")])
self.assertEqual(len(self.keys(d)), 2)
def test_elements_without_uuid_stay_apart_and_are_marked(self):
"""Keying them on geometry collapsed nine distinct conductors onto
one key, which was worse than the instability being fixed."""
d = self.diagram([("", "1", 0, 0), ("", "2", 0, 0), ("", "3", 0, 0)],
[("1", "2"), ("2", "3"), ("1", "3")])
keys = self.keys(d)
self.assertEqual(len(keys), 3)
self.assertTrue(all("#" in k for k in keys))
def test_current_format_uses_element_and_terminal(self):
"""A terminal uuid belongs to the definition, so two coils of one type
share it; the (instance, terminal) pair is the identity."""
d = ET.Element("diagram")
for a, b in (("{e1}", "{e2}"), ("{e1}", "{e3}")):
ET.SubElement(d, "conductor", {"element1": a, "terminal1": "{T}",
"element2": b, "terminal2": "{T}"})
self.assertEqual(len(self.keys(d)), 2)
class ChecksDefinition(unittest.TestCase):
def test_all_checks_are_read_only_selects(self):
for name, c in m.CHECKS.items():
with self.subTest(check=name):
self.assertRegex(c["sql"].lstrip().upper(), r"^(SELECT|WITH)\b")
self.assertNotRegex(c["sql"].upper(), r"\b(DELETE|UPDATE|INSERT|DROP|ALTER)\b")
self.assertIn(c["severity"], ("error", "warning", "info"))
self.assertTrue(c["note"])
def test_text_comparisons_treat_null_as_empty(self):
"""A value never set is NULL for an element placed in this session
and '' for one loaded from a file; `col = ''` matches only the
latter. Every comparison must go through COALESCE."""
for name, c in m.CHECKS.items():
for col in ("label", "manufacturer_reference", "wire_number"):
for m_ in re.finditer(rf"(?)\s*''", c["sql"]):
self.fail(f"{name}: bare {col} comparison at {m_.start()} misses NULL")
def test_unknown_check_and_bad_sample(self):
with tempfile.NamedTemporaryFile(suffix=".qet") as f:
with self.assertRaises(ValueError):
m.tool_check("/x", f.name, checks=["nope"])
with self.assertRaises(ValueError):
m.tool_check("/x", f.name, sample=-1)
class PathPolicy(unittest.TestCase):
"""The workspace confinement on model-supplied paths.
Both AI security reviews on PR #980 flagged unrestricted filesystem
reach as the headline MCP-side risk, one of them as the single blocker
before merge. These check the control itself rather than the tools
behind it, so they stay fast and hermetic.
"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name) / "workspace"
self.root.mkdir()
self.outside = Path(self.tmp.name) / "outside"
self.outside.mkdir()
(self.outside / "secret.qet").write_text("")
(self.root / "ok.qet").write_text("")
self._saved = dict(os.environ)
os.environ["QET_MCP_WORKSPACE"] = str(self.root)
os.environ.pop("QET_MCP_ALLOW_ANY_PATH", None)
self.qet = fake_qet(Path(self.tmp.name) / "bin")
os.environ["QET_BINARY"] = str(self.qet)
def tearDown(self):
os.environ.clear()
os.environ.update(self._saved)
self.tmp.cleanup()
def test_read_inside_the_workspace_is_allowed(self):
m.enforce_path_policy("qet_project_info", {"path": str(self.root / "ok.qet")})
def test_read_outside_the_workspace_is_refused(self):
with self.assertRaisesRegex(ValueError, "outside the workspace"):
m.enforce_path_policy("qet_project_info",
{"path": str(self.outside / "secret.qet")})
def test_traversal_out_of_the_workspace_is_refused(self):
sneaky = str(self.root / ".." / "outside" / "secret.qet")
with self.assertRaisesRegex(ValueError, "outside the workspace"):
m.enforce_path_policy("qet_project_info", {"path": sneaky})
def test_symlink_escape_is_refused(self):
"""A link planted inside the workspace is judged by where it points.
This is the case a string-prefix check gets wrong, which is why the
policy resolves before comparing.
"""
link = self.root / "innocent.qet"
link.symlink_to(self.outside / "secret.qet")
with self.assertRaisesRegex(ValueError, "outside the workspace"):
m.enforce_path_policy("qet_project_info", {"path": str(link)})
def test_write_outside_the_workspace_is_refused(self):
with self.assertRaisesRegex(ValueError, "outside the workspace"):
m.enforce_path_policy("qet_export", {
"binary": str(self.qet),
"project": str(self.root / "ok.qet"),
"format": "pdf",
"output": str(self.outside / "exfiltrated.pdf")})
def test_existing_output_is_not_clobbered_without_overwrite(self):
target = self.root / "existing.qet"
target.write_text("precious")
args = {"binary": str(self.qet), "output": str(target), "title": "T"}
with self.assertRaisesRegex(ValueError, "already exists"):
m.enforce_path_policy("qet_project_new", args)
# ... and goes through once the caller says so explicitly
m.enforce_path_policy("qet_project_new", dict(args, overwrite=True))
self.assertEqual(target.read_text(), "precious", "policy must not itself write")
def test_new_output_needs_no_overwrite_flag(self):
m.enforce_path_policy("qet_project_new", {
"binary": str(self.qet),
"output": str(self.root / "brand_new.qet"), "title": "T"})
def test_operation_level_file_paths_are_checked(self):
"""add_image/add_pdf_page carry their own path, one level down."""
base = {"binary": str(self.qet),
"project": str(self.root / "ok.qet"),
"output": str(self.root / "out.qet")}
outside_png = str(self.outside / "anything.png")
for op in ({"op": "add_image", "folio": 0, "file": outside_png, "x": 0, "y": 0},
{"op": "add_pdf_page", "folio": 0, "file": outside_png,
"page": 1, "dpi": 150, "x": 0, "y": 0}):
with self.subTest(op=op["op"]):
with self.assertRaisesRegex(ValueError, "outside the workspace"):
m.enforce_path_policy("qet_edit", dict(base, operations=[op]))
def test_several_roots_may_be_allowed(self):
os.environ["QET_MCP_WORKSPACE"] = os.pathsep.join(
[str(self.root), str(self.outside)])
m.enforce_path_policy("qet_project_info", {"path": str(self.outside / "secret.qet")})
def test_escape_hatch_disables_confinement(self):
os.environ["QET_MCP_ALLOW_ANY_PATH"] = "1"
self.assertEqual(m.workspace_roots(), [])
m.enforce_path_policy("qet_project_info", {"path": "/etc/passwd"})
def test_defaults_to_the_working_directory_not_to_everything(self):
os.environ.pop("QET_MCP_WORKSPACE", None)
roots = m.workspace_roots()
self.assertEqual(roots, [Path(os.getcwd()).resolve()],
"an unset workspace must still confine, not open up")
def test_tools_without_data_paths_are_untouched(self):
m.enforce_path_policy("qet_diff", {})
def test_every_write_tool_offers_the_overwrite_flag(self):
"""The policy and the schemas must not drift apart.
A tool whose output the policy guards but whose schema never
mentions "overwrite" is a tool no client can ever replace a file
with; a tool that advertises the flag but is not in the policy is a
tool that silently clobbers. Both are one forgotten line away, so
the two lists are compared rather than trusted.
"""
guarded = {name for name, spec in m._DATA_PATHS.items() if spec.get("write")}
advertised = {t["name"] for t in m.TOOLS
if "overwrite" in t["inputSchema"].get("properties", {})}
self.assertEqual(guarded, advertised - m._OVERWRITE_OWN_FILE)
def test_every_data_path_argument_is_guarded(self):
"""The other direction: a tool whose schema takes a data path must be
in the policy, or that path is read or written with no workspace
check at all -- and nothing fails. binary and elements_dir have
their own rule (BinaryPolicy)."""
pathish = {"path", "project", "before", "after", "output", "directory"}
for t in m.TOOLS:
with self.subTest(tool=t["name"]):
props = t["inputSchema"].get("properties", {})
spec = m._DATA_PATHS.get(t["name"], {})
guarded = set(spec.get("read", ())) | set(spec.get("write", ()))
self.assertEqual(pathish & set(props), guarded & pathish,
f"{t['name']}: data path arguments not in _DATA_PATHS")
def test_the_policy_names_only_real_tools_and_arguments(self):
by_name = {t["name"]: t for t in m.TOOLS}
for name, spec in m._DATA_PATHS.items():
with self.subTest(tool=name):
self.assertIn(name, by_name, "policy guards a tool that does not exist")
props = by_name[name]["inputSchema"].get("properties", {})
for arg in tuple(spec.get("read", ())) + tuple(spec.get("write", ())):
self.assertIn(arg, props,
f"{name} has no {arg!r} argument to guard")
class BinaryPolicy(unittest.TestCase):
"""The program the server launches is not the client's to choose (F063).
"binary" used to be exempt from every check as "configuration", but it
is a per-call argument: any executable file it named was run, with the
client's own paths as arguments.
"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
base = Path(self.tmp.name)
self.root = base / "workspace"
self.root.mkdir()
(self.root / "ok.qet").write_text("")
self.qet = fake_qet(base / "bin")
self.other = fake_qet(base / "other")
self._saved = dict(os.environ)
os.environ["QET_MCP_WORKSPACE"] = str(self.root)
os.environ["QET_BINARY"] = str(self.qet)
# A PATH with no qelectrotech on it, so only what a test sets counts.
os.environ["PATH"] = str(base / "empty")
for var in ("QET_MCP_ALLOW_ANY_PATH", "QET_MCP_ALLOW_ANY_BINARY",
"QET_MCP_BINARIES", "QET_MCP_ELEMENTS"):
os.environ.pop(var, None)
def tearDown(self):
os.environ.clear()
os.environ.update(self._saved)
self.tmp.cleanup()
def call(self, **extra):
args = dict({"project": str(self.root / "ok.qet")}, **extra)
m.enforce_path_policy("qet_query", args)
return args
def test_left_out_it_is_filled_in(self):
self.assertEqual(self.call()["binary"], str(self.qet.resolve()))
def test_the_configured_one_is_accepted(self):
self.assertEqual(self.call(binary=str(self.qet))["binary"], str(self.qet.resolve()))
def test_any_other_program_is_refused(self):
with self.assertRaisesRegex(ValueError, "not an allowed QElectroTech"):
self.call(binary=str(self.other))
def test_a_program_inside_the_workspace_is_refused_too(self):
"""Being inside the workspace makes a file readable, not runnable."""
planted = fake_qet(self.root, "run.sh")
with self.assertRaisesRegex(ValueError, "not an allowed QElectroTech"):
self.call(binary=str(planted))
def test_a_symlink_is_judged_by_its_target(self):
link = self.root / "qelectrotech"
link.symlink_to(self.other)
with self.assertRaisesRegex(ValueError, "not an allowed QElectroTech"):
self.call(binary=str(link))
good = self.root / "also-qet"
good.symlink_to(self.qet)
self.call(binary=str(good))
def test_others_can_be_listed(self):
os.environ["QET_MCP_BINARIES"] = str(self.other)
self.assertEqual(self.call(binary=str(self.other))["binary"], str(self.other.resolve()))
def test_the_check_can_be_switched_off(self):
os.environ["QET_MCP_ALLOW_ANY_BINARY"] = "1"
self.assertEqual(self.call(binary=str(self.other))["binary"], str(self.other))
def test_found_on_path(self):
del os.environ["QET_BINARY"]
os.environ["PATH"] = str(self.qet.parent)
self.assertEqual(self.call()["binary"], str(self.qet.resolve()))
def test_none_found_says_what_to_set(self):
del os.environ["QET_BINARY"]
with self.assertRaisesRegex(ValueError, "set QET_BINARY"):
self.call()
def test_every_tool_that_takes_binary_is_checked(self):
"""A tool whose schema offers "binary" but that the policy skips
would run whatever it was given."""
takes = {t["name"] for t in m.TOOLS
if "binary" in t["inputSchema"].get("properties", {})}
self.assertEqual(takes, m._LAUNCHES_QET | set(m._LAUNCHES_QET_WITH))
for name in takes:
self.assertNotIn("binary", m._BY_NAME[name]["inputSchema"].get("required", []),
f"{name} still requires the client to name a binary")
def test_elements_dir_outside_the_workspace_is_refused(self):
with self.assertRaisesRegex(ValueError, "'elements_dir' is outside"):
self.call(elements_dir=self.tmp.name)
def test_elements_dir_can_be_listed(self):
coll = Path(self.tmp.name) / "collection"
coll.mkdir()
os.environ["QET_MCP_ELEMENTS"] = str(coll)
self.call(elements_dir=str(coll / "10_electric"))
def test_an_install_finds_its_own_qet_and_collection(self):
"""Installed as /share/qelectrotech/mcp/qet_mcp.py, the
server needs no configuration at all."""
import importlib.util
prefix = Path(self.tmp.name) / "prefix"
mcp = prefix / "share" / "qelectrotech" / "mcp"
mcp.mkdir(parents=True)
(prefix / "share" / "qelectrotech" / "elements").mkdir()
shutil.copy2(HERE / "qet_mcp.py", mcp / "qet_mcp.py")
exe = fake_qet(prefix / "bin")
del os.environ["QET_BINARY"]
spec = importlib.util.spec_from_file_location("qet_mcp_installed", mcp / "qet_mcp.py")
inst = importlib.util.module_from_spec(spec)
spec.loader.exec_module(inst)
args = {"project": str(self.root / "ok.qet")}
inst.enforce_path_policy("qet_query", args)
self.assertEqual(args["binary"], str(exe.resolve()))
self.assertEqual(args["elements_dir"],
str((prefix / "share" / "qelectrotech" / "elements").resolve()))
def installed_at(self, root: Path, mcp: Path):
"""Import a copy of the server placed at mcp/qet_mcp.py."""
import importlib.util
mcp.mkdir(parents=True)
shutil.copy2(HERE / "qet_mcp.py", mcp / "qet_mcp.py")
spec = importlib.util.spec_from_file_location(f"qet_mcp_{root.name}", mcp / "qet_mcp.py")
inst = importlib.util.module_from_spec(spec)
spec.loader.exec_module(inst)
return inst
def test_the_windows_layout_is_found_too(self):
"""The Windows installers and the portable folder put the server in
/mcp, the program in /bin as QElectroTech.exe, and the
collection in /elements."""
root = Path(self.tmp.name) / "QElectroTech"
(root / "elements").mkdir(parents=True)
exe = fake_qet(root / "bin", "QElectroTech.exe")
inst = self.installed_at(root, root / "mcp")
del os.environ["QET_BINARY"]
args = {"project": str(self.root / "ok.qet")}
inst.enforce_path_policy("qet_query", args)
# samefile: on a file system that ignores case (macOS, Windows)
# the server finds it under another of its spellings.
self.assertTrue(Path(args["binary"]).samefile(exe), args["binary"])
self.assertEqual(args["elements_dir"], str((root / "elements").resolve()))
def test_a_copy_saved_anywhere_is_not_an_install(self):
"""A downloaded copy sitting beside some bin/ folder must not go
looking for QElectroTech there."""
home = Path(self.tmp.name) / "home"
fake_qet(home / "bin")
inst = self.installed_at(home, home / "Downloads")
self.assertIsNone(inst._installation())
def test_a_folder_named_mcp_is_not_an_install(self):
"""Without a bin/ beside it, an mcp/ folder is just a folder."""
inst = self.installed_at(Path(self.tmp.name) / "x", Path(self.tmp.name) / "x" / "mcp")
self.assertIsNone(inst._installation())
class LaunchExecutable(unittest.TestCase):
"""Windows cannot run a lone copy of QElectroTech (F065): its DLLs sit
beside the original. Everywhere else the private copy stays."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
base = Path(self.tmp.name)
self.src = base / "bin" / "qelectrotech"
self.src.parent.mkdir()
self.src.write_text("#!/bin/sh\nexit 0\n")
self.sandbox = base / "sandbox"
self.sandbox.mkdir()
def tearDown(self):
self.tmp.cleanup()
def test_windows_runs_the_original(self):
self.assertEqual(m._launch_executable(self.src, self.sandbox, True), self.src)
self.assertEqual(list(self.sandbox.iterdir()), [], "nothing is copied on Windows")
def test_windows_keeps_its_own_qt_platform(self):
"""The Windows packages have no offscreen plugin; asking for it
leaves QElectroTech stuck at a message box."""
env = m._launch_env({"PATH": "x"}, self.sandbox, True)
self.assertNotIn("QT_QPA_PLATFORM", env)
self.assertEqual(env["PATH"], "x")
def test_elsewhere_runs_offscreen(self):
env = m._launch_env({}, self.sandbox, False)
self.assertEqual(env["QT_QPA_PLATFORM"], "offscreen")
self.assertEqual(env["HOME"], str(self.sandbox))
def test_settings_stay_in_the_sandbox_everywhere(self):
"""HOME and XDG move the settings only on Linux; QET_SETTINGS_DIR
moves them on Windows and macOS too (#1178)."""
for windows in (True, False):
env = m._launch_env({}, self.sandbox, windows)
self.assertEqual(env["QET_SETTINGS_DIR"], str(self.sandbox / ".config"))
def test_elsewhere_runs_a_private_copy(self):
exe = m._launch_executable(self.src, self.sandbox, False)
self.assertEqual(exe.parent, self.sandbox)
self.assertNotEqual(exe, self.src)
self.assertEqual(exe.read_text(), self.src.read_text())
class ElementsDirSetting(unittest.TestCase):
"""elements_dir reaches QElectroTech as a settings file in the sandbox
(#1178): the .ini that a QElectroTech knowing QET_SETTINGS_DIR reads on
every system, and the .conf an older one reads on Linux."""
def test_both_files_carry_the_collection(self):
seen = {}
def run(argv, **kwargs):
cfg = Path(kwargs["env"]["QET_SETTINGS_DIR"]) / "QElectroTech"
for f in cfg.iterdir():
seen[f.name] = f.read_text(encoding="utf-8")
return subprocess.CompletedProcess(argv, 0, "", "")
with tempfile.TemporaryDirectory() as coll, \
mock.patch.object(m.subprocess, "run", run):
m._run_qet(TRUE, ["x.qet"], elements_dir=coll)
want = m._collection_setting(Path(coll))
self.assertEqual(seen, {"QElectroTech.ini": want, "QElectroTech.conf": want})
self.assertIn(f"common-collection-path={Path(coll).as_posix()}", want)
def test_a_windows_path_has_forward_slashes(self):
"""Qt reads a backslash in the file as an escape."""
from pathlib import PureWindowsPath
text = m._collection_setting(PureWindowsPath(r"C:\x\custom"))
self.assertIn("common-collection-path=C:/x/custom\n", text)
self.assertNotIn("\\", text)
class ScriptingDisabledHint(unittest.TestCase):
"""QElectroTech may refuse to run scripts at all, and says so in French.
Scripting is off by default from qelectrotech-source-mirror#984 on, so
every tool here that drives QElectroTech through --run comes back empty
until somebody sets QET_ENABLE_SCRIPTING=1 in the environment this
server was started in. The refusal has to arrive as an instruction the
caller can act on, not as exit code 3 and a paragraph of French, and
not as one of the older guesses ("is it a build with --run support?")
that happen to fit the same symptom.
subprocess.run is faked, so these are about the reading of the result
and cost no launch.
"""
REFUSAL = ("Les scripts sont désactivés.\n\nPour l'activer : Configurer "
"QElectroTech > Général > Projets, ou définir la variable "
"d'environnement QET_ENABLE_SCRIPTING=1 pour une exécution "
"sans interface (CI, traitement par lot).")
def fake_run(self, returncode, stderr="", stdout=""):
def run(argv, **kwargs):
return subprocess.CompletedProcess(argv, returncode, stdout, stderr)
return run
def call(self, returncode, stderr="", **kw):
saved = m.subprocess.run
m.subprocess.run = self.fake_run(returncode, stderr)
try:
# TRUE only has to exist and be executable: it is copied
# into the sandbox and then never actually launched.
return m._run_qet(TRUE, ["x.qet"], **kw)
finally:
m.subprocess.run = saved
def test_a_refused_script_says_which_variable_to_set(self):
r = self.call(3, self.REFUSAL, script="qet.log('hi')")
self.assertFalse(r["ok"])
self.assertIn("QET_ENABLE_SCRIPTING=1", r["hint"])
self.assertIn("env", r["hint"])
def test_the_hint_survives_a_reworded_refusal(self):
# Keyed on the variable name first, exit 3 as the fallback, so a
# future build that words this differently still gets read right.
r = self.call(3, "scripting is disabled", script="qet.log('hi')")
self.assertIn("QET_ENABLE_SCRIPTING=1", r["hint"])
def test_a_launch_with_no_script_is_not_blamed_on_scripting(self):
# qet_export uses a plain CLI flag and keeps working with scripting
# off, so its failures must never be explained this way.
r = self.call(3, "Project not found: x.qet")
self.assertNotIn("hint", r)
def test_a_successful_script_gets_no_hint(self):
r = self.call(0, "", script="qet.log('hi')")
self.assertTrue(r["ok"])
self.assertNotIn("hint", r)
def test_the_specific_hint_is_not_overwritten_by_a_generic_one(self):
"""The two older guesses fit the same symptom and must yield to it.
Scripting being off produces no capability report either, so
tool_edit() and tool_project_new() would otherwise answer "is it a
build with --run support?" -- sending the reader to check the one
thing that is fine.
"""
source = Path(m.__file__).read_text()
for marker in ("the binary never ran the script",
"QElectroTech did not write the project"):
with self.subTest(hint=marker[:30]):
idx = source.index(marker)
before = source[max(0, idx - 400):idx]
self.assertIn('setdefault("hint"', before,
"this hint would clobber a more specific one")
def test_every_script_driven_tool_is_named_in_the_hint(self):
"""The hint lists which tools need the variable; keep it true.
A tool that starts driving QElectroTech through a script and is
missing from that list leaves its caller reading a message that
says the problem is somewhere else.
"""
source = Path(m.__file__).read_text()
# Tool handlers that pass script= to _run_qet.
driven = set(re.findall(r"^def (tool_\w+)", source, re.M))
script_driven = set()
for name in driven:
body = source[source.index(f"def {name}("):]
body = body[:body.find("\ndef ") if "\ndef " in body else len(body)]
if "script=script" in body or "script=\"\\n\".join(script)" in body:
script_driven.add("qet_" + name[len("tool_"):])
self.assertTrue(script_driven, "found no script-driven tools to check")
hint = source[source.index("QET_ENABLE_SCRIPTING=1 to the"):][:900]
for tool in sorted(script_driven):
with self.subTest(tool=tool):
self.assertIn(tool, hint)
NOTE_SCRIPT = (
"// ==QETScript==\n"
"// @name Add a note\n"
"// @tooltip Puts a note on the folio on screen\n"
"// @shortcut Ctrl+Alt+N\n"
"// ==/QETScript==\n"
"qet.addText(qet.currentFolio(), 'SCRIPT-NOTE', 40, 40);\n"
"qet.log('added');\n")
ICON = ''
class ScriptHeader(unittest.TestCase):
"""The same cases as QElectroTech's tst_scriptheader: a header this
accepts and QElectroTech refuses would install a script that never gets
a button."""
def h(self, lines):
return m.parse_script_header(
"// ==QETScript==\n" + lines + "// ==/QETScript==\nqet.log('x');\n", "a")
def test_full(self):
h = self.h("// @name Add revision note\n// @icon note.svg\n"
"// @tooltip Puts a note\n// @shortcut Ctrl+Alt+R\n"
"// @context selection\n// @api 1\n")
self.assertNotIn("error", h)
self.assertEqual((h["name"], h["icon"], h["shortcut"], h["context"]),
("Add revision note", "note.svg", "Ctrl+Alt+R", "selection"))
self.assertEqual(h["action_id"], "diagrameditor.script.a")
def test_refused(self):
cases = {
"qet.log('x');\n": "no // ==QETScript== header",
}
for text, error in cases.items():
self.assertEqual(m.parse_script_header(text, "a")["error"], error)
for lines, error in [
("// @icon x.svg\n", "@name is required"),
("// @name A\n// @shortcutt Ctrl+K\n", "unknown header key @shortcutt"),
("// @name A\n// @context wires\n",
"@context must be one of: canvas, selection, conductor"),
("// @name A\n// @api 2\n",
"@api 2 is not supported by this version (1 is)")]:
with self.subTest(lines=lines):
self.assertEqual(self.h(lines)["error"], error)
def test_only_the_block(self):
h = m.parse_script_header(
"// ==QETScript==\n// @name Real\n// ==/QETScript==\n"
"// @name Not this one\n// @bogus key\n", "a")
self.assertNotIn("error", h)
self.assertEqual(h["name"], "Real")
class ScriptStore(unittest.TestCase):
"""install / list / read / remove against a scripts folder of its own."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = Path(self.tmp.name) / "scripts"
self.env = mock.patch.dict(os.environ, {"QET_MCP_SCRIPTS_DIR": str(self.dir),
"QET_ENABLE_SCRIPTING": "1"})
self.env.start()
def tearDown(self):
self.env.stop()
self.tmp.cleanup()
def test_round_trip(self):
r = m.tool_script_install("add-note", NOTE_SCRIPT)
self.assertTrue(r["ok"])
self.assertEqual((self.dir / "add-note.js").read_text(), NOTE_SCRIPT)
listed = m.tool_script_list()
self.assertEqual([s["id"] for s in listed["scripts"]], ["add-note"])
self.assertEqual(listed["scripts"][0]["shortcut"], "Ctrl+Alt+N")
self.assertEqual(m.tool_script_read("add-note")["source"], NOTE_SCRIPT)
m.tool_script_remove("add-note")
self.assertFalse((self.dir / "add-note.js").exists())
self.assertEqual(m.tool_script_list()["scripts"], [])
def test_icon_stored_and_removed_with_it(self):
src = NOTE_SCRIPT.replace("// @tooltip", "// @icon iconic.svg\n// @tooltip")
m.tool_script_install("iconic", src, icon_svg=ICON)
self.assertEqual((self.dir / "iconic.svg").read_text(), ICON)
self.assertEqual(m.tool_script_read("iconic")["icon_svg"], ICON)
m.tool_script_remove("iconic")
self.assertEqual(list(self.dir.iterdir()), [])
def test_shared_icon_kept_while_used(self):
src = NOTE_SCRIPT.replace("// @tooltip", "// @icon one.svg\n// @tooltip")
m.tool_script_install("one", src, icon_svg=ICON)
m.tool_script_install("two", src) # names one.svg, which exists
m.tool_script_remove("one")
self.assertTrue((self.dir / "one.svg").exists())
m.tool_script_remove("two")
self.assertFalse((self.dir / "one.svg").exists())
def test_refusals(self):
for sid in ("../evil", "Upper", "", "a/b", "-x", "x" * 65, None):
with self.subTest(id=sid), self.assertRaisesRegex(ValueError, "'id' must be"):
m.tool_script_install(sid, NOTE_SCRIPT)
with self.assertRaisesRegex(ValueError, "would refuse this header: @name"):
m.tool_script_install("a", "// ==QETScript==\n// ==/QETScript==\n")
with self.assertRaisesRegex(ValueError, "must say '// @icon a.svg'"):
m.tool_script_install("a", NOTE_SCRIPT, icon_svg=ICON)
with self.assertRaisesRegex(ValueError, "not in"):
m.tool_script_install("a", NOTE_SCRIPT.replace(
"// @tooltip", "// @icon missing.svg\n// @tooltip"))
src = NOTE_SCRIPT.replace("// @tooltip", "// @icon a.svg\n// @tooltip")
for bad in ("", "