Merge master into fix/qet-mcp-free-texts-only

Conflict in _extras(): keep master's tables and folio uuids and _angle(),
and read only the folio's own inputs/shapes/images as this branch does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-09-28 16:26:31 +13:00
11 changed files with 2837 additions and 991 deletions
+1582 -925
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -30,6 +30,7 @@ here read the model.
| `qet_project_info` | title, format version, folios, element and conductor counts |
| `qet_elements` | placed elements: uuid, type, position, label, information bag |
| `qet_conductors` | conductors and their documentation fields; filter by attribute |
| `qet_items` | free texts, shapes, pictures, tables and symbol text fields, each with its uuid |
| `qet_diff` | **what an edit actually changed** — element moves, adds, removes, relabels; conductor changes; and folio fields, texts, shapes, images, symbol text fields and terminal strips |
| `qet_scan` | sweep a directory of projects, counting nodes carrying an attribute |
| `qet_element_info` | a `.elmt`: translated names, terminals, info fields, part counts |
@@ -264,6 +265,14 @@ Python, plus the hang guard on `addConductor` and the database refresh in
## Notes and limits
- **Two ways of numbering folios.** Tools that read the file —
`qet_project_info`, `qet_elements`, `qet_conductors`, `qet_diff` — number
folios from 1, as the application does. Tools that pass a folio to
QElectroTech's scripting API — `qet_edit` and `qet_continuity` — take an
index counted from 0, so the folio `qet_elements` calls 1 is `0` there.
`qet_continuity` refuses an index with no folio instead of reporting it
clean, and each of its findings carries both `folio` (the index) and
`folio_number` (counted from 1).
- **The project database is reachable now, through `qet_query`.** It was
not when this server was written, which is why every other structural
tool here re-derives its answer from the XML. Prefer the views —
+267 -49
View File
@@ -118,10 +118,17 @@ def _elements(root: ET.Element):
yield i, el
def _wires(diagram: ET.Element):
"""The folio's conductors: children of <conductors> only. A folio's wire
numbering rule is also saved as a <conductor> tag, under <autonum>, and
is not a wire."""
return diagram.findall("conductors/conductor")
def _conductors(root: ET.Element):
for i, d in _folios(root):
index = _terminal_index(d)
for c in d.iter("conductor"):
for c in _wires(d):
yield i, c, index
@@ -245,7 +252,7 @@ def tool_project_info(path: str) -> dict:
"index": i,
"title": d.get("title", ""),
"elements": sum(1 for _ in d.iter("element")),
"conductors": sum(1 for _ in d.iter("conductor")),
"conductors": len(_wires(d)),
})
return {
"file": str(Path(path).expanduser()),
@@ -272,6 +279,36 @@ def tool_elements(path: str, folio: int | None = None,
"elements": rows[:limit]}
ITEM_KINDS = ["text", "shape", "image", "table", "element_text"]
def tool_items(path: str, folio: int | None = None, kind: str | None = None,
limit: int = 500) -> dict:
"""Every drawn item that is not a symbol or a wire, with its uuid.
Free texts, shapes, pictures, tables and the text fields of symbols --
the items a qet_edit op or a qet_diff entry names by uuid. Folios are
numbered from 1, as in qet_elements. An item saved before these items
carried a uuid has "" here; QElectroTech gives it one on the next save.
"""
if kind is not None and kind not in ITEM_KINDS:
raise ValueError(f"kind must be one of {ITEM_KINDS}, not {kind!r}")
ex = _extras(_root(path))
rows = []
for name, records in (("text", ex["texts"]), ("shape", ex["shapes"]),
("image", ex["images"]), ("table", ex["tables"])):
for r in records:
rows.append({"kind": name, "uuid": r["uuid"], **r["label"], **r["value"]})
for k, v in ex["element_texts"].items():
rows.append({"kind": "element_text", "uuid": ex["element_text_uuids"][k],
"folio": ex["element_text_folios"][k], "element": k[0],
"source": k[1], "bound_to": k[2], "n": k[3], **v})
rows = [r for r in rows if (folio is None or r["folio"] == folio)
and (kind is None or r["kind"] == kind)]
rows.sort(key=lambda r: (r["folio"], ITEM_KINDS.index(r["kind"])))
return {"count": len(rows), "truncated": len(rows) > limit, "items": rows[:limit]}
def tool_conductors(path: str, folio: int | None = None,
attribute: str | None = None,
non_empty: bool = False, limit: int = 200) -> dict:
@@ -312,6 +349,21 @@ def _plain_text(html: str) -> str:
return " ".join(inner.split())
def _angle(value: str) -> str:
"""A rotation in degrees, reduced to [0, 360) so equal angles compare equal.
QElectroTech writes the same angle in more than one way: rotating a symbol
and undoing it leaves its text fields at "-270" where they were "90", or
"-90" where they were "270". Compared as written, that read as a change.
Anything that is not a number is returned unchanged.
"""
try:
deg = float(value) % 360
except (TypeError, ValueError):
return value
return f"{deg:g}"
def _extras(root: ET.Element) -> dict:
"""Everything a folio holds besides elements and conductors.
@@ -321,7 +373,7 @@ def _extras(root: ET.Element) -> dict:
Older files have only position to go on, and there a change reads as
the old one removed and a new one added, with both shown.
"""
folios, texts, shapes, images = {}, [], [], []
folios, folio_uuids, texts, shapes, images, tables = {}, {}, [], [], [], []
def record(el, label, value):
return {"uuid": el.get("uuid", ""), "key": tuple(label.values()),
@@ -329,6 +381,12 @@ def _extras(root: ET.Element) -> dict:
for n, d in _folios(root):
folios[n] = {f: d.get(f, "") for f in _FOLIO_FIELDS}
folio_uuids[n] = d.get("uuid", "")
for tb in d.findall("tables/graphics_table"):
tables.append(record(
tb, {"folio": n, "name": tb.get("name", "")},
{"x": tb.get("x", ""), "y": tb.get("y", ""), "width": tb.get("width", ""),
"height": tb.get("height", ""), "rows_shown": tb.get("display_n_row", "")}))
# The folio's own items only: direct children of its <inputs>,
# <shapes> and <images>. Symbols in older files carry their own
# <inputs><input> texts, which iter() would count as free texts
@@ -337,7 +395,7 @@ def _extras(root: ET.Element) -> dict:
texts.append(record(
t, {"folio": n, "x": t.get("x", ""), "y": t.get("y", ""),
"text": _plain_text(t.get("text", ""))},
{"rotation": t.get("rotation", "0"),
{"rotation": _angle(t.get("rotation", "0")),
"font": t.get("font", ""), "color": t.get("color", "")}))
for sh in d.findall("shapes/shape"):
pen, brush = sh.find("pen"), sh.find("brush")
@@ -350,13 +408,13 @@ def _extras(root: ET.Element) -> dict:
"line_width": pen.get("widthF", "") if pen is not None else "",
"fill": (brush.get("color", "") if brush is not None and
brush.get("style", "") != "NoBrush" else "none"),
"rotation": sh.get("rotation", "0")}))
"rotation": _angle(sh.get("rotation", "0"))}))
for im in d.findall("images/image"):
images.append(record(
im, {"folio": n, "x": im.get("x", ""), "y": im.get("y", "")},
{"scale": im.get("size", ""), "rotation": im.get("rotation", "")}))
{"scale": im.get("size", ""), "rotation": _angle(im.get("rotation", ""))}))
element_texts = {}
element_texts, element_text_uuids, element_text_folios = {}, {}, {}
for n, d in _folios(root):
for el in d.iter("element"):
uuid = el.get("uuid", "")
@@ -369,9 +427,11 @@ def _extras(root: ET.Element) -> dict:
base = (uuid, src, what)
seen[base] = seen.get(base, 0) + 1
fs = (t.get("font", "").split(",") + ["", ""])[1]
element_text_uuids[base + (seen[base],)] = t.get("uuid", "")
element_text_folios[base + (seen[base],)] = n
element_texts[base + (seen[base],)] = {
"x": t.get("x", ""), "y": t.get("y", ""), "size": fs,
"frame": t.get("frame", ""), "rotation": t.get("rotation", ""),
"frame": t.get("frame", ""), "rotation": _angle(t.get("rotation", "")),
"width": t.get("text_width", ""),
"shows": t.findtext("text") or ""}
@@ -386,8 +446,18 @@ def _extras(root: ET.Element) -> dict:
"location": info.get("location", ""),
"name": info.get("name", ""),
"terminals": sum(1 for _ in st.iter("real_terminal"))}
return {"folios": folios, "texts": texts, "shapes": shapes,
"images": images, "strips": strips, "element_texts": element_texts}
return {"folios": folios, "folio_uuids": folio_uuids, "texts": texts, "shapes": shapes,
"images": images, "tables": tables, "strips": strips,
"element_texts": element_texts, "element_text_uuids": element_text_uuids,
"element_text_folios": element_text_folios}
def _usable_ids(*sides) -> bool:
"""Whether uuids can identify items: present on every item, unique on each
side. Copying a symbol keeps its text fields' uuids, so a project can
hold the same field uuid twenty times; keying on it would merge them."""
return (any(sides) and all(all(s) for s in sides)
and all(len(set(s)) == len(s) for s in sides))
def _diff_keyed(a: dict, b: dict, label) -> dict:
@@ -413,7 +483,7 @@ def _diff_items(a: list, b: list) -> dict:
and re-added. On uuid, position is part of what is compared, so a move
is a change to that item.
"""
by_uuid = all(r["uuid"] for r in a + b)
by_uuid = _usable_ids([r["uuid"] for r in a], [r["uuid"] for r in b])
def key(r):
return r["uuid"] if by_uuid else str(r["key"])
@@ -429,34 +499,87 @@ def _diff_items(a: list, b: list) -> dict:
return out
def _diff_folios(a: dict, b: dict) -> dict:
"""Folio fields, keyed by the folio's uuid when every folio has one.
By uuid, a folio moved to another position is reported once, under
"reordered", instead of as every folio after it changing its fields.
Without uuids (older files) folios are keyed by position, and a
removal or reorder in the middle shifts every later index -- the note
says so when the count changed.
"""
ua, ub = a["folio_uuids"], b["folio_uuids"]
by_uuid = _usable_ids(list(ua.values()), list(ub.values()))
changed, reordered, added, removed = [], [], [], []
if by_uuid:
pos_a = {u: n for n, u in ua.items()}
pos_b = {u: n for n, u in ub.items()}
for u in sorted(set(pos_a) & set(pos_b), key=lambda u: pos_b[u]):
fa, fb = a["folios"][pos_a[u]], b["folios"][pos_b[u]]
delta = {f: [fa[f], fb[f]] for f in _FOLIO_FIELDS if fa[f] != fb[f]}
if delta:
changed.append({"folio": pos_b[u], "uuid": u, "changed": delta})
if pos_a[u] != pos_b[u]:
reordered.append({"uuid": u, "title": fb["title"],
"from": pos_a[u], "to": pos_b[u]})
added = [{"folio": pos_b[u], "uuid": u, "title": b["folios"][pos_b[u]]["title"]}
for u in sorted(set(pos_b) - set(pos_a), key=lambda u: pos_b[u])]
removed = [{"folio": pos_a[u], "uuid": u, "title": a["folios"][pos_a[u]]["title"]}
for u in sorted(set(pos_a) - set(pos_b), key=lambda u: pos_a[u])]
else:
for n in sorted(set(a["folios"]) & set(b["folios"])):
delta = {f: [a["folios"][n][f], b["folios"][n][f]] for f in _FOLIO_FIELDS
if a["folios"][n][f] != b["folios"][n][f]}
if delta:
changed.append({"folio": n, "changed": delta})
out = {"before": len(a["folios"]), "after": len(b["folios"]),
"keyed_by": "uuid" if by_uuid else "position", "changed": changed[:50]}
if by_uuid:
out.update(added=added[:50], removed=removed[:50], reordered=reordered[:50])
elif len(a["folios"]) != len(b["folios"]) and changed:
out["note"] = ("the folio count changed, so changes listed here may be "
"later folios shifting position rather than edits")
return out
def _diff_element_texts(a: dict, b: dict) -> dict:
"""Element text fields, keyed by their own uuid when every field has one.
Otherwise by element, what the field is bound to, and the nth such field
-- which cannot tell a field that was removed from one that moved down
the list. A field's own text is also compared ("shows"), so relabelling
an element shows up here as well as in the element's information.
"""
# A field's uuid is unique only within its symbol (copies keep them), so
# a field is identified by its symbol's uuid and its own.
ka = {k: (k[0], u) if k[0] and u else "" for k, u in a["element_text_uuids"].items()}
kb = {k: (k[0], u) if k[0] and u else "" for k, u in b["element_text_uuids"].items()}
by_uuid = _usable_ids(list(ka.values()), list(kb.values()))
def label(k):
return {"element": k[0], "source": k[1], "bound_to": k[2], "n": k[3]}
if not by_uuid:
out = _diff_keyed(a["element_texts"], b["element_texts"], label)
out["keyed_by"] = "position"
return out
labels = {u: {**label(k), "uuid": u[1]} for side in (ka, kb) for k, u in side.items()}
out = _diff_keyed({ka[k]: v for k, v in a["element_texts"].items()},
{kb[k]: v for k, v in b["element_texts"].items()},
lambda u: labels[u])
out["keyed_by"] = "uuid"
return out
def _diff_extras(before: ET.Element, after: ET.Element) -> dict:
a, b = _extras(before), _extras(after)
out = {}
ta, tb = before.get("title", ""), after.get("title", "")
out["project"] = {"changed": {"title": [ta, tb]} if ta != tb else {}}
# Folios are keyed by position. A reorder or a removal in the middle
# shifts every later index, so a folio "changing" its title alongside a
# folio count change can just be the shift -- the count says which.
folio_changes = []
for n in sorted(set(a["folios"]) & set(b["folios"])):
delta = {f: [a["folios"][n][f], b["folios"][n][f]] for f in _FOLIO_FIELDS
if a["folios"][n][f] != b["folios"][n][f]}
if delta:
folio_changes.append({"folio": n, "changed": delta})
out["folios"] = {"before": len(a["folios"]), "after": len(b["folios"]),
"changed": folio_changes[:50]}
if len(a["folios"]) != len(b["folios"]) and folio_changes:
out["folios"]["note"] = ("the folio count changed, so changes listed here may be "
"later folios shifting position rather than edits")
out["folios"] = _diff_folios(a, b)
out["texts"] = _diff_items(a["texts"], b["texts"])
out["shapes"] = _diff_items(a["shapes"], b["shapes"])
out["images"] = _diff_items(a["images"], b["images"])
# Keyed by element, what the field is bound to, and the nth such field.
# A field's own text is also compared ("shows"), so relabelling an
# element shows up here as well as in the element's information.
out["element_texts"] = _diff_keyed(
a["element_texts"], b["element_texts"],
lambda k: {"element": k[0], "source": k[1], "bound_to": k[2], "n": k[3]})
out["tables"] = _diff_items(a["tables"], b["tables"])
out["element_texts"] = _diff_element_texts(a, b)
out["terminal_strips"] = _diff_keyed(
a["strips"], b["strips"],
lambda k: (lambda v: f"{v['installation']} {v['location']} {v['name']}".strip())(
@@ -476,16 +599,24 @@ def tool_diff(before: str, after: str) -> dict:
a_el, b_el = {}, {}
for i, e in _elements(_root(before)):
r = _element_row(i, e)
# Rotation is saved as "orientation", in quarter turns (0-3); it is
# the only thing a rotation changes, so without it a rotated symbol
# reads as untouched.
r["orientation"] = e.get("orientation", "0")
a_el[r["uuid"] or f"{i}:{r['x']},{r['y']}:{r['name']}"] = r
for i, e in _elements(_root(after)):
r = _element_row(i, e)
r["orientation"] = e.get("orientation", "0")
b_el[r["uuid"] or f"{i}:{r['x']},{r['y']}:{r['name']}"] = r
moved, relabelled, changed_info = [], [], []
moved, rotated, relabelled, changed_info = [], [], [], []
for k, a in a_el.items():
b = b_el.get(k)
if b is None:
continue
if a["orientation"] != b["orientation"]:
rotated.append({"uuid": k, "name": a["name"], "folio": a["folio"],
"orientation": [a["orientation"], b["orientation"]]})
if (a["x"], a["y"]) != (b["x"], b["y"]):
moved.append({
"uuid": k, "name": a["name"], "folio": a["folio"],
@@ -505,15 +636,21 @@ def tool_diff(before: str, after: str) -> dict:
changed_info.append({"uuid": k, "name": a["name"],
"from": a_info, "to": b_info})
a_co = {r["key"]: r for i, c, ix in _conductors(_root(before))
for r in [_conductor_row(i, c, ix)]}
b_co = {r["key"]: r for i, c, ix in _conductors(_root(after))
for r in [_conductor_row(i, c, ix)]}
a_rows = [_conductor_row(i, c, ix) for i, c, ix in _conductors(_root(before))]
b_rows = [_conductor_row(i, c, ix) for i, c, ix in _conductors(_root(after))]
# Keyed by the conductor's own uuid when every conductor on both sides
# has one, so a rewired conductor is that conductor, changed ("ends").
# QElectroTech keeps a uuid only on conductors that were loaded with one
# or created since, so an older file keys on its two ends instead.
co_by_uuid = _usable_ids([r["uuid"] for r in a_rows], [r["uuid"] for r in b_rows])
co_id = (lambda r: r["uuid"]) if co_by_uuid else (lambda r: r["key"])
a_co = {co_id(r): r for r in a_rows}
b_co = {co_id(r): r for r in b_rows}
# An end that could not be resolved to an element is keyed on the
# folio-scoped integer id, which QElectroTech reassigns on every write.
# Say so rather than presenting the result as if it were comparable:
# in such a file an untouched conductor can read as removed and re-added.
shaky = sum(1 for k in set(a_co) | set(b_co) if "#" in k)
shaky = 0 if co_by_uuid else sum(1 for k in set(a_co) | set(b_co) if "#" in k)
unstable = {} if not shaky else {
"unstable_keys": shaky,
"warning": "some conductors sit on elements with no persisted uuid, so "
@@ -530,8 +667,11 @@ def tool_diff(before: str, after: str) -> dict:
("num", "formula", "cable", "bus", "color", "section",
"function", "type")
if a[f] != b[f]}
if a["key"] != b["key"]:
fields["ends"] = [a["key"], b["key"]]
if fields:
conductor_changes.append({"key": k, "changed": fields})
conductor_changes.append({"key": b["key"], **({"uuid": k} if co_by_uuid else {}),
"changed": fields})
deltas = sorted({tuple(m["delta"]) for m in moved})
return {
@@ -544,11 +684,13 @@ def tool_diff(before: str, after: str) -> dict:
"distinct_move_deltas": [list(d) for d in deltas],
"relabelled": relabelled[:50],
"info_changed": changed_info[:50],
"rotated": rotated[:50],
},
"conductors": {
"before": len(a_co), "after": len(b_co),
"added": sorted(set(b_co) - set(a_co))[:50],
"removed": sorted(set(a_co) - set(b_co))[:50],
"keyed_by": "uuid" if co_by_uuid else "ends",
"added": sorted(b_co[k]["key"] for k in set(b_co) - set(a_co))[:50],
"removed": sorted(a_co[k]["key"] for k in set(a_co) - set(b_co))[:50],
"changed": conductor_changes[:100],
"changed_count": len(conductor_changes),
**unstable,
@@ -1132,10 +1274,10 @@ OPS = {
("source", "str"), ("value", "str"),
("x", "num"), ("y", "num")]),
"set_element_text": ("setElementTextProperty", [("folio", "folio"), ("element", "elmt"),
("index", "folio"), ("property", "str"),
("index", "element_text"), ("property", "str"),
("value", "str")]),
"delete_element_text": ("deleteElementText", [("folio", "folio"), ("element", "elmt"),
("index", "folio")]),
("index", "element_text")]),
# Returns the uuids of the copies IN THE ORDER the elements were named,
# so "$copies[0]" is the copy of the first one. Conductors between the
# copied elements are copied with them; copies arrive without labels or
@@ -1195,9 +1337,9 @@ OPS = {
("closed", "bool")]),
"add_table": ("addTable", [("folio", "folio"), ("kind", "str"),
("name", "str"), ("query", "str")]),
"set_table_position": ("setTablePosition", [("folio", "folio"), ("table", "folio"),
"set_table_position": ("setTablePosition", [("folio", "folio"), ("table", "table"),
("x", "num"), ("y", "num")]),
"delete_table": ("deleteTable", [("folio", "folio"), ("table", "folio")]),
"delete_table": ("deleteTable", [("folio", "folio"), ("table", "table")]),
}
SHAPES = ["line", "rectangle", "ellipse", "polygon"]
@@ -1251,6 +1393,7 @@ def _build_script(operations: list, output: str) -> str:
# an index-only edit still runs on a build that predates them.
uuid_methods: set[str] = set()
folio_js = "0"
element_js = None # the op's element, for lookups scoped to it
lines = [
"// generated by qet-mcp; do not edit",
"var R = {};", # $name -> value from an earlier op
@@ -1304,6 +1447,26 @@ def _build_script(operations: list, output: str) -> str:
raise ValueError(f"operation {op_index}: {key!r} must be a non-empty list of "
f"integer indices, got {value!r}")
return _js(value)
if kind == "table":
# As for texts below: a uuid is resolved to the current index at
# run time, since deleting an earlier table shifts every index.
if isinstance(value, str) and _UUID_RE.fullmatch(value):
uuid_methods.add("tableIndex")
return f"qet.tableIndex({folio_js}, {_js(value)})"
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError(f"operation {op_index}: {key!r} must be a table index "
f"or its uuid, got {value!r}")
return _js(value)
if kind == "element_text":
# A field's uuid is unique only within its element (copies keep
# them), so the lookup takes the op's element too.
if isinstance(value, str) and _UUID_RE.fullmatch(value):
uuid_methods.add("elementTextIndex")
return f"qet.elementTextIndex({folio_js}, {element_js}, {_js(value)})"
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError(f"operation {op_index}: {key!r} must be a text field index "
f"or its uuid, got {value!r}")
return _js(value)
if kind in ("text", "shape", "image"):
# A uuid names the item for good; it is turned into the index
# the call takes at run time, by the item's own folio.
@@ -1403,6 +1566,7 @@ def _build_script(operations: list, output: str) -> str:
if key not in op:
raise ValueError(f"operation {i} ({name}) is missing {key!r}")
folio_js = args[0] if args else "0"
element_js = args[1] if len(args) > 1 else None
args.append(ref_or(op[key], kind, i, key))
ident = op.get("id")
@@ -1586,6 +1750,17 @@ def tool_continuity(binary: str, project: str, folio: int | None = None,
proj = Path(project).expanduser()
if not proj.is_file():
raise ValueError(f"no such project: {proj}")
if folio is not None:
# qet.checkContinuity() answers an index it has no folio for with an
# empty list, which reads exactly like a clean folio. The index counts
# from 0 while qet_elements numbers folios from 1, so the likely
# mistake -- passing the last folio's number -- would pass silently.
count = len(list(_folios(_root(str(proj)))))
if not 0 <= folio < count:
raise ValueError(
f"folio {folio} does not exist: the project has {count} folio(s), "
f"indexed 0 to {count - 1} here. qet_continuity counts folios from 0; "
"the folio qet_elements calls N is N - 1.")
folio_arg = -1 if folio is None else folio
script = ("var out = qet.checkContinuity(%s);\n"
@@ -1615,6 +1790,12 @@ def tool_continuity(binary: str, project: str, folio: int | None = None,
result.setdefault("hint", "no findings came back at all -- this build's "
"scripting API may predate qet.checkContinuity()")
return result
for f in findings:
# "folio" is the 0-based index qet.checkContinuity() uses; add the
# number qet_elements and the application show, so the two can be
# matched without arithmetic.
if isinstance(f, dict) and isinstance(f.get("folio"), int):
f["folio_number"] = f["folio"] + 1
result["findings"] = findings
result["finding_count"] = len(findings)
result["errors"] = sum(1 for f in findings if f.get("severity") == "error")
@@ -2137,6 +2318,26 @@ TOOLS = [
},
"handler": lambda a: tool_project_info(a["path"]),
},
{
"name": "qet_items",
"description": "List the drawn items that are not symbols or wires -- free texts, "
"shapes, pictures, tables and the text fields of symbols -- with "
"each one's uuid, folio (counted from 1) and main fields. Use the "
"uuid to address an item in qet_edit or to find it in qet_diff. "
"Reads the file directly; does not launch QElectroTech.",
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string"},
"folio": {"type": "integer", "description": "folio number counted from 1"},
"kind": {"type": "string", "enum": ITEM_KINDS},
"limit": {"type": "integer", "default": 500},
},
"required": ["path"],
},
"handler": lambda a: tool_items(a["path"], a.get("folio"), a.get("kind"),
a.get("limit", 500)),
},
{
"name": "qet_elements",
"description": "List placed elements with uuid, type, position, label and "
@@ -2166,7 +2367,8 @@ TOOLS = [
"type": "object",
"properties": {
"path": {"type": "string"},
"folio": {"type": "integer"},
"folio": {"type": "integer",
"description": "folio number counted from 1, as qet_elements and the application show it"},
"attribute": {"type": "string",
"description": "an XML attribute of <conductor>, e.g. cable"},
"non_empty": {"type": "boolean", "default": False},
@@ -2182,9 +2384,14 @@ TOOLS = [
{
"name": "qet_diff",
"description": "Structurally diff two .qet files: which elements moved and "
"by what delta, which were added, removed or relabelled, and "
"which conductor fields changed. Use this to verify what an "
"edit actually did, rather than reading a screenshot.",
"by what delta, which were rotated (orientation in quarter "
"turns, 0-3), which were added, removed or relabelled, and "
"which conductor fields changed; also folio fields, texts, shapes, "
"pictures, tables, symbol text fields and terminal strips. Items are "
"matched by their uuid when every one of a kind has one (each "
"section says so in \"keyed_by\"), otherwise by position or ends. "
"Use this to verify what an edit actually did, rather than reading "
"a screenshot.",
"inputSchema": {
"type": "object",
"properties": {
@@ -2419,6 +2626,11 @@ TOOLS = [
"shape's points can reorder it relative to the others -- re-list "
"before addressing one by index again if more than one is being "
"edited in the same run, or address it by uuid. "
"set_table_position/delete_table take a table's index or its uuid, "
"and set_element_text/delete_element_text a text field's index or "
"its uuid (the field's own, looked up within the op's element) "
"-- a uuid still names the right item after an "
"earlier one is deleted. "
"Tables: add_table places a BOM/nomenclature or summary table "
"(kind is \"nomenclature\" or \"summary\") built from a query "
"against a project database view -- run qet.query() (the "
@@ -2516,7 +2728,12 @@ TOOLS = [
"properties": {
"binary": {"type": "string", "description": "path to the qelectrotech executable"},
"project": {"type": "string", "description": "the .qet to check; never modified"},
"folio": {"type": "integer", "description": "check one folio only; omit for the whole project"},
"folio": {"type": "integer", "description":
"check one folio only; omit for the whole project. An index "
"counted from 0, like qet_edit: the folio qet_elements calls 1 "
"is 0 here. An index with no folio is refused, not reported "
"clean. Each finding carries both \"folio\" (this index) and "
"\"folio_number\" (counted from 1)."},
"elements_dir": {"type": "string"},
"timeout": {"type": "integer", "default": 180},
},
@@ -2705,6 +2922,7 @@ _BY_NAME = {t["name"]: t for t in TOOLS}
_DATA_PATHS = {
"qet_project_info": {"read": ("path",)},
"qet_elements": {"read": ("path",)},
"qet_items": {"read": ("path",)},
"qet_conductors": {"read": ("path",)},
"qet_diff": {"read": ("before", "after")},
"qet_scan": {"read": ("directory",)},
+873 -1
View File
@@ -153,7 +153,7 @@ class ToolRegistry(unittest.TestCase):
"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_continuity", "qet_items"})
class EditValidation(unittest.TestCase):
@@ -167,6 +167,33 @@ class EditValidation(unittest.TestCase):
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}])
self.assertIn(f'qet.deleteElementText(R["f"], R["k"], qet.elementTextIndex(R["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_every_op_generates_a_script(self):
# one minimal valid instance of every op
f = {"op": "add_folio", "id": "f"}
@@ -707,6 +734,648 @@ class WrongFolioHint(unittest.TestCase):
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(
'<project title="T" version="0.100">'
'<diagram title="One"><elements>'
f'<element uuid="{A}" type="embed://import/x/Coil.elmt" x="10" y="20">'
'<terminals><terminal id="1" x="0" y="-10" orientation="n"/>'
'<terminal id="2" x="0" y="10" orientation="s"/></terminals>'
'<elementInformations>'
'<elementInformation show="1" name="label">K1</elementInformation>'
'<elementInformation show="1" name="comment"> spaced </elementInformation>'
'<elementInformation show="1">no name, ignored</elementInformation>'
'</elementInformations></element>'
f'<element uuid="{B}" type="embed://import/x/relay_coil.elmt" x="30" y="40"/>'
f'<element uuid="{C}" type="embed://import/x/lamp.elmt" x="50" y="60"/>'
'</elements><conductors>'
'<conductor terminal1="1" terminal2="2" num="W1" formula="F" cable="C1" bus="B"'
' function="L1" conductor_color="red" conductor_section="1.5" type="multi"/>'
f'<conductor element1="{B}" terminal1="" terminalname1="A1"'
f' element2="{C}" terminal2="{{t2}}" num="W2" cable=" "/>'
'</conductors></diagram>'
'<diagram title="Two"><elements>'
f'<element uuid="{D}" type="embed://import/x/Coil.elmt" x="1" y="2"/>'
'</elements><conductors>'
f'<conductor element1="{D}" terminal1="{{t1}}" element2="{D}" terminal2="{{t2}}"/>'
'</conductors></diagram></project>')
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, "title": "One", "elements": 3, "conductors": 2},
{"index": 2, "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'<element uuid="{{{i}}}" type="x.elmt"/>' for i in range(201))
wires = "".join(f'<conductor terminal1="{i}" terminal2="{i}"/>' for i in range(201))
big = Path(self.tmp.name) / "big.qet"
big.write_text(f'<project><diagram><elements>{els}</elements>'
f'<conductors>{wires}</conductors></diagram></project>')
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_row_without_an_index(self):
c = ET.fromstring('<conductor terminal1="7" terminal2="8"/>')
self.assertEqual(m._conductor_row(3, c)["key"], "3:#7--#8")
def test_plain_text(self):
self.assertEqual(m._plain_text("<html><body><p>a &amp; <b>b</b></p>\n<p> c </p></body></html>"),
"a & b c")
# no <body>: the whole string is the text
self.assertEqual(m._plain_text("plain <b>x</b>"), "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(
'<project><diagram><elements>'
'<element uuid="{el}" type="x.elmt" x="0" y="0"><dynamic_texts>'
'<dynamic_elmt_text uuid="{et}" x="5" y="6" rotation="0" text_width="-1" frame="false"'
' font="Sans,9,-1" text_from="ElementInfo"><text>K1</text><info_name>label</info_name>'
'</dynamic_elmt_text></dynamic_texts></element></elements><conductors/>'
'<inputs><input uuid="{tx}" x="1" y="2" rotation="0" font="f" color="c"'
' text="&lt;html&gt;&lt;body&gt;note&lt;/body&gt;&lt;/html&gt;"/></inputs>'
'<shapes><shape uuid="{sh}" type="Line" x1="0" y1="0" x2="9" y2="9" rotation="0">'
'<pen color="c" style="s" widthF="1"/></shape></shapes>'
'</diagram><diagram><elements/><conductors/>'
'<images><image x="3" y="4" size="1" rotation="0"/></images>'
'<tables><graphics_table uuid="{tb}" name="Parts" x="0" y="0" width="100" height="50"'
' display_n_row="10"/></tables>'
'</diagram></project>')
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'<input uuid="{{{i}}}" x="{i}" y="0" text="t"/>' for i in range(501))
big = Path(self.tmp.name) / "big.qet"
big.write_text(f'<project><diagram><inputs>{many}</inputs></diagram></project>')
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'<project title="{title}">{body}</project>')
return str(p)
@staticmethod
def el(uuid, x, y, label=None, comment=None, type_="x/coil.elmt", texts=""):
info = "".join(f'<elementInformation show="1" name="{n}">{v}</elementInformation>'
for n, v in (("label", label), ("comment", comment)) if v is not None)
u = f' uuid="{uuid}"' if uuid else ""
return (f'<element{u} type="{type_}" x="{x}" y="{y}">'
f'<elementInformations>{info}</elementInformations>'
f'<dynamic_texts>{texts}</dynamic_texts></element>')
@staticmethod
def wire(e1, e2, **attrs):
a = "".join(f' {k}="{v}"' for k, v in attrs.items())
return f'<conductor element1="{e1}" terminal1="{{t1}}" element2="{e2}" terminal2="{{t2}}"{a}/>'
def folio(self, elements="", wires="", extra="", **attrs):
a = "".join(f' {k}="{v}"' for k, v in attrs.items())
return (f'<diagram{a}><elements>{elements}</elements>'
f'<conductors>{wires}</conductors>{extra}</diagram>')
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'<dynamic_elmt_text x="0" y="0" rotation="{rot}" text_from="UserText"><text>t</text>'
'</dynamic_elmt_text>'))
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),
'<conductor terminal1="3" terminal2="4" num="W1"/>'))
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'<inputs><input{u("t")} x="{x}" y="2" rotation="0" font="f" color="{color}"'
' text="&lt;html&gt;&lt;body&gt;note&lt;/body&gt;&lt;/html&gt;"/></inputs>'
f'<shapes><shape{u("s")} type="Rectangle" x1="{x}" y1="0" x2="9" y2="9" rotation="0">'
f'<pen color="{color}" style="SolidLine" widthF="1"/><brush style="NoBrush"/></shape></shapes>'
f'<images><image{u("i")} x="{x}" y="3" size="1" rotation="0"/></images>')
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'<shapes><shape uuid="{{s}}" type="Line" x1="0" y1="0" x2="1" y2="1" rotation="90">'
f'<pen color="#000" style="{style}" widthF="{width}"/>{brush}</shape></shapes>')
d = m.tool_diff(
self.qet(self.folio(extra=shape("SolidLine", 1, '<brush style="NoBrush" color="#0f0"/>'))),
self.qet(self.folio(extra=shape("DashLine", 2, '<brush style="SolidPattern" color="#0f0"/>'))))
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'<dynamic_elmt_text x="{x}" y="1" frame="false" rotation="0" text_width="-1" '
f'text_from="ElementInfo" font="Sans,{size},-1"><text>{shows}</text>'
'<info_name>label</info_name></dynamic_elmt_text>')
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 ('<inputs><input uuid="{t}" x="1" y="2" '
+ " ".join(f'{k}="{w}"' for k, w in a.items()) + ' text="x"/></inputs>')
def shape(rot):
return (f'<shapes><shape uuid="{{s}}" type="Line" x1="0" y1="0" x2="1" y2="1" '
f'rotation="{rot}"><pen color="c" style="s" widthF="1"/></shape></shapes>')
def image(**v):
a = {"size": "1", "rotation": "0", **v}
return ('<images><image uuid="{i}" x="0" y="0" '
+ " ".join(f'{k}="{w}"' for k, w in a.items()) + '/></images>')
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=(
"<dynamic_elmt_text " + " ".join(f'{k}="{w}"' for k, w in a.items())
+ ' text_from="CompositeText"><text>K1</text>'
'<composite_text>%{label}</composite_text></dynamic_elmt_text>'))
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() + '<terminal_strips><terminal_strip/></terminal_strips>')
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: "<inputs>" + "".join(
f'<input uuid="{U(i)}" x="{x}" y="0" text="t"/>' for i in range(n)) + "</inputs>"
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'<dynamic_elmt_text uuid="{u}" x="{x}" y="0" text_from="UserText"><text>{t}</text>'
'</dynamic_elmt_text>' 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'<dynamic_elmt_text x="{x}" y="0" text_from="UserText"><text>{t}</text></dynamic_elmt_text>'
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: ('<dynamic_elmt_text uuid="{same}" x="' + str(x) + '" y="0" '
'text_from="UserText"><text>t</text></dynamic_elmt_text>')
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: ('<dynamic_elmt_text uuid="{e1}" x="' + str(x) + '" y="0" '
'text_from="UserText"><text>t</text></dynamic_elmt_text>')
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'<dynamic_elmt_text uuid="{{dup}}" x="{v}" y="0" text_from="UserText"><text>{v}</text>'
'</dynamic_elmt_text>' 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='<shapes>' + ''.join(
f'<shape uuid="{{s}}" type="Line" x1="{i}" y1="0" x2="1" y2="1"/>' for i in (0, 5)) + '</shapes>'))
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='<dynamic_elmt_text uuid="{e1}" x="0" y="0" '
'text_from="UserText"><text>t</text></dynamic_elmt_text>')
+ 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 ('<tables><graphics_table uuid="{tb}" name="P" '
+ " ".join(f'{k}="{w}"' for k, w in a.items()) + '/></tables>')
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: ('<tables><graphics_table uuid="{tb}" name="Parts" x="' + str(x)
+ f'" y="0" width="100" height="50" display_n_row="{rows}"/></tables>')
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'<real_terminal/>' for _ in range(n))
return ('<terminal_strips><terminal_strip><terminal_strip_data uuid="{st}">'
'<informations><information name="installation">I</information>'
'<information name="location">L</information>'
f'<information name="name">{name}</information></informations>'
f'</terminal_strip_data>{terms}</terminal_strip></terminal_strips>')
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()
@@ -1099,6 +1768,20 @@ class PathPolicy(unittest.TestCase):
if "overwrite" in t["inputSchema"].get("properties", {})}
self.assertEqual(guarded, advertised)
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 are
configuration, deliberately not confined (see the README)."""
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():
@@ -1275,6 +1958,25 @@ class QueryGuard(unittest.TestCase):
m.tool_query("/x", "/no/such.qet", "SELECT 1")
class ContinuityFolioGuard(unittest.TestCase):
"""qet.checkContinuity() answers a folio index it has no folio for with an
empty list, which looks like a clean folio; refuse it before launch."""
def test_an_index_with_no_folio_is_refused_before_launch(self):
with tempfile.TemporaryDirectory() as d:
qet = Path(d) / "p.qet"
qet.write_text("<project><diagram/><diagram/></project>")
for folio in (2, 5, -1):
with self.subTest(folio=folio):
with self.assertRaisesRegex(ValueError, r"2 folio\(s\), indexed 0 to 1"):
m.tool_continuity("/nonexistent", str(qet), folio=folio)
def test_the_descriptions_say_how_folios_are_counted(self):
props = {t["name"]: t["inputSchema"]["properties"] for t in m.TOOLS}
self.assertIn("counted from 0", props["qet_continuity"]["folio"]["description"])
self.assertIn("counted from 1", props["qet_conductors"]["folio"]["description"])
class ProjectNewValidation(unittest.TestCase):
def test_refuses_overwrite_and_bad_arguments(self):
with tempfile.TemporaryDirectory() as tmp:
@@ -1305,6 +2007,23 @@ class ReadTools(unittest.TestCase):
# (value, how many nodes carry it)
self.assertEqual([tuple(v) for v in scan["distinct_values"]], [("C1", 1)])
def test_a_wire_numbering_rule_is_not_a_wire(self):
"""A folio's conductor numbering rule is saved as <autonum><conductor>;
counting every <conductor> tag reported it as a wire with no ends."""
with tempfile.TemporaryDirectory() as tmp:
rule = ('<autonum><conductor><part increase="1" type="unit" value="3"/>'
'</conductor></autonum>')
wire = '<conductors><conductor terminal1="1" terminal2="2" num="W1"/></conductors>'
a, b = Path(tmp) / "a.qet", Path(tmp) / "b.qet"
a.write_text(f'<project><diagram>{rule}<elements/>{wire}</diagram></project>')
b.write_text(f'<project><diagram><elements/>{wire}</diagram></project>')
self.assertEqual(m.tool_project_info(str(a))["folios"][0]["conductors"], 1)
self.assertEqual(m.tool_project_info(str(a))["conductor_count"], 1)
self.assertEqual([c["num"] for c in m.tool_conductors(str(a))["conductors"]], ["W1"])
# Removing only the rule changes no wire.
c = m.tool_diff(str(a), str(b))["conductors"]
self.assertEqual((c["added"], c["removed"]), ([], []))
# ==========================================================================
# protocol
@@ -2486,6 +3205,15 @@ class Integration(unittest.TestCase):
self.assertGreater(both["finding_count"], 0)
self.assertEqual(only_1["finding_count"], 0, "folio 1 has no elements at all")
def test_continuity_findings_carry_the_folio_number_qet_elements_uses(self):
base = self.sb.new(folios=2)
r = self.ok(self.sb.edit(base, [
{"op": "add_element", "id": "a", "folio": 1, "path": COIL, "x": 100, "y": 100}]))
found = m.tool_continuity(BINARY, r["output"], folio=1, elements_dir=ELEMENTS)["findings"]
self.assertTrue(found)
self.assertEqual({(f["folio"], f["folio_number"]) for f in found}, {(1, 2)})
self.assertEqual({e["folio"] for e in m.tool_elements(r["output"])["elements"]}, {2})
def test_continuity_finds_a_report_link_colour_mismatch(self):
"""Reproduces qelectrotech/qelectrotech-source-mirror#974: a
folio-jump conductor drawn in two different colours on either side
@@ -2790,8 +3518,152 @@ class PlcIntegration(unittest.TestCase):
self.assertTrue(r["operations"][-1]["succeeded"])
@needs_examples
class UuidIndexLookups(unittest.TestCase):
"""qet.tableIndex() and qet.elementTextIndex(): a uuid in, the index the
other calls take out, as qet.textIndex() does for free texts. The index is
what shifts when an earlier item is deleted; the uuid is what holds."""
def run_script(self, project, body):
r = m._run_qet(BINARY, [str(project)], elements_dir=ELEMENTS or None,
script=body + '\nqet.log("OUT " + JSON.stringify(out));', tail=200_000)
line = next((ln for ln in (r["stdout"] + "\n" + r["stderr"]).splitlines() if "OUT " in ln), None)
self.assertIsNotNone(line, r.get("stderr", "")[-500:])
return json.loads(line.split("OUT ", 1)[1])
def test_table_index_follows_the_table_across_a_deletion(self):
root = ET.parse(Path(EXAMPLES) / "industrial.qet").getroot()
folio, table = next((i, d.find("tables/graphics_table")) for i, d in enumerate(root.iter("diagram"))
if d.find("tables/graphics_table") is not None)
twin = ET.fromstring(ET.tostring(table))
twin.set("uuid", "{11111111-2222-4333-8444-555555555555}")
twin.set("x", str(float(table.get("x")) + 900))
list(root.iter("diagram"))[folio].find("tables").append(twin)
with tempfile.TemporaryDirectory() as tmp:
p = Path(tmp) / "two_tables.qet"
ET.ElementTree(root).write(p, encoding="utf-8")
first, second = table.get("uuid"), twin.get("uuid")
out = self.run_script(p, f"""
var out = {{}};
out.first = qet.tableIndex({folio}, "{first}");
out.second = qet.tableIndex({folio}, "{second}");
out.bogus = qet.tableIndex({folio}, "{{00000000-0000-4000-8000-000000000000}}");
out.not_uuid = qet.tableIndex({folio}, "table");
out.list = qet.tables({folio});
qet.deleteTable({folio}, out.first);
out.second_after = qet.tableIndex({folio}, "{second}");
out.first_after = qet.tableIndex({folio}, "{first}");""")
self.assertEqual(sorted([out["first"], out["second"]]), [0, 1])
# each index names the right table: the twin sits 900 further right
at = lambda i: float(re.search(r"at \(([-\d.]+),", out["list"][i]).group(1))
self.assertEqual(at(out["second"]) - at(out["first"]), 900)
self.assertEqual((out["bogus"], out["not_uuid"]), (-1, -1))
self.assertEqual((out["second_after"], out["first_after"]), (0, -1))
def test_qet_edit_deletes_then_moves_tables_by_uuid(self):
"""Delete one table, then move the other, both by uuid. By index the
second op would name the wrong table: deleting table 0 shifts table 1."""
text = (Path(EXAMPLES) / "industrial.qet").read_text(encoding="utf-8")
root = ET.fromstring(text)
folio, table = next((i, d.find("tables/graphics_table")) for i, d in enumerate(root.iter("diagram"))
if d.find("tables/graphics_table") is not None)
twin = ET.fromstring(ET.tostring(table))
twin.set("uuid", "{11111111-2222-4333-8444-555555555555}")
twin.set("x", str(float(table.get("x")) + 900))
# Insert into the raw text: re-serialising the whole file with
# ElementTree rewrites the embedded SVG logo's namespace, which
# QElectroTech then saves without its declaration.
start = text.index(f'uuid="{table.get("uuid")}"')
end = text.index("</graphics_table>", start) + len("</graphics_table>")
text = text[:end] + ET.tostring(twin, encoding="unicode") + text[end:]
with tempfile.TemporaryDirectory() as tmp:
src, out = Path(tmp) / "two.qet", Path(tmp) / "out.qet"
src.write_text(text, encoding="utf-8")
r = m.tool_edit(BINARY, str(src), [
{"op": "delete_table", "folio": folio, "table": table.get("uuid")},
{"op": "set_table_position", "folio": folio, "table": twin.get("uuid"), "x": 120, "y": 340}],
str(out), elements_dir=ELEMENTS or None)
self.assertTrue(r["ok"], r.get("hint"))
left = list(ET.parse(out).getroot().iter("diagram"))[folio].findall("tables/graphics_table")
self.assertEqual([(t.get("uuid"), float(t.get("x")), float(t.get("y"))) for t in left],
[(twin.get("uuid"), 120.0, 340.0)])
def test_qet_edit_edits_one_copys_field_by_uuid(self):
"""Two copies of a symbol share a field uuid; addressing it with the
symbol changes that copy's field only."""
root = ET.parse(Path(EXAMPLES) / "2612_ats_singlephase.qet").getroot()
owners = {}
for i, d in enumerate(root.iter("diagram")):
for el in d.iter("element"):
for t in el.findall("dynamic_texts/dynamic_elmt_text"):
if t.get("uuid"):
owners.setdefault((i, t.get("uuid")), []).append(el.get("uuid"))
(folio, field), (a, b) = next((k, v[:2]) for k, v in owners.items() if len(v) >= 2)
with tempfile.TemporaryDirectory() as tmp:
src, out = Path(tmp) / "in.qet", Path(tmp) / "out.qet"
shutil.copy(Path(EXAMPLES) / "2612_ats_singlephase.qet", src)
r = m.tool_edit(BINARY, str(src), [
{"op": "set_element_text", "folio": folio, "element": b, "index": field,
"property": "x", "value": "77"}], str(out), elements_dir=ELEMENTS or None)
self.assertTrue(r["ok"], r.get("hint"))
x_of = lambda path, el_uuid: next(
t.get("x") for el in ET.parse(path).getroot().iter("element") if el.get("uuid") == el_uuid
for t in el.findall("dynamic_texts/dynamic_elmt_text") if t.get("uuid") == field)
self.assertEqual(float(x_of(out, b)), 77.0)
self.assertEqual(x_of(out, a), x_of(src, a))
def test_element_text_index_needs_the_element_as_well(self):
"""Copies of a symbol share their text fields' uuids (2612_ats_singlephase.qet):
the same field uuid resolves on each copy to that copy's own field."""
root = ET.parse(Path(EXAMPLES) / "2612_ats_singlephase.qet").getroot()
owners = {}
for i, d in enumerate(root.iter("diagram")):
for el in d.iter("element"):
for t in el.iter("dynamic_elmt_text"):
if t.get("uuid"):
owners.setdefault((i, t.get("uuid")), []).append((el.get("uuid"), t.findtext("text")))
(folio, field), copies = next((k, v) for k, v in owners.items()
if len(v) >= 2 and len({s for _, s in v}) >= 2)
(a, shows_a), (b, shows_b) = copies[:2]
out = self.run_script(Path(EXAMPLES) / "2612_ats_singlephase.qet", f"""
var out = {{}};
out.ia = qet.elementTextIndex({folio}, "{a}", "{field}");
out.ib = qet.elementTextIndex({folio}, "{b}", "{field}");
out.la = qet.elementTexts({folio}, "{a}");
out.lb = qet.elementTexts({folio}, "{b}");
out.bogus = qet.elementTextIndex({folio}, "{a}", "{{00000000-0000-4000-8000-000000000000}}");
out.no_element = qet.elementTextIndex({folio}, "{{00000000-0000-4000-8000-000000000001}}", "{field}");""")
self.assertGreaterEqual(out["ia"], 0)
self.assertGreaterEqual(out["ib"], 0)
self.assertIn(f"shows='{shows_a}'", out["la"][out["ia"]])
self.assertIn(f"shows='{shows_b}'", out["lb"][out["ib"]])
self.assertEqual((out["bogus"], out["no_element"]), (-1, -1))
@needs_examples
class CorpusIntegration(unittest.TestCase):
def test_folio_counts_match_what_qelectrotech_itself_holds(self):
"""Element and conductor counts per folio, from the file, against
QElectroTech's own counts after loading it -- over every example.
schema_indus.qet caught a numbering rule counted as a wire."""
script = ('var o = [];'
'for (var f = 0; f < qet.folioCount(); f++)'
' o.push([qet.elementCount(f), qet.conductorCount(f)]);'
'qet.log("COUNTS " + JSON.stringify(o));')
checked = 0
for f in sorted(Path(EXAMPLES).glob("*.qet")):
with self.subTest(project=f.name):
r = m._run_qet(BINARY, [str(f)], elements_dir=ELEMENTS or None,
script=script, tail=200_000)
line = next(ln for ln in (r["stdout"] + "\n" + r["stderr"]).splitlines()
if "COUNTS " in ln)
qet = json.loads(line.split("COUNTS ", 1)[1])
mcp = [[x["elements"], x["conductors"]]
for x in m.tool_project_info(str(f))["folios"]]
self.assertEqual(mcp, qet)
checked += 1
self.assertGreater(checked, 20)
def test_conductor_keys_never_collide_across_the_shipped_examples(self):
"""Keying on terminal geometry alone merged nine distinct conductors
of schema_indus.qet; the shipped corpus is the check."""
@@ -45,7 +45,9 @@
#include <limits>
#include "../qetapp.h"
#include "../qetpalette.h"
#include "../shortcutmanager.h"
#include "elementpreviewdelegate.h"
#include "elementslocation.h"
//The palette is read from disk each time the picker opens. It is meant as
@@ -53,6 +55,25 @@
//slow and the grid unusable.
static const int max_palette_entries = 60;
/**
@brief elementIcon
@return the element preview @a icon, adapted to @a widget's palette at
@a size. The shortcut bar and its editor mix these with command icons,
which already follow the palette through the icon theme, so the
element icons are adapted once here rather than by an
ElementPreviewDelegate over the whole list. An item dragged from one
list to another keeps its adapted icon.
*/
static QIcon elementIcon(const QIcon &icon, const QSize &size, const QWidget *widget)
{
if (icon.isNull() || !QET::Palette::isDark(widget->palette())) {
return icon;
}
return QIcon(QET::Palette::forPalette(
icon.pixmap(size, widget->devicePixelRatio()),
widget->palette()));
}
/**
@brief ElementPickerPopup::ElementPickerPopup
@param source : the collection widget whose model the search runs against
@@ -217,6 +238,8 @@ ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source,
m_model = new QStandardItemModel(this);
m_view = new QListView(this);
m_view->setModel(m_model);
//Element previews are black line art; adapt them to a dark palette
m_view->setItemDelegate(new ElementPreviewDelegate(m_view));
m_view->setIconSize(QSize(40, 40));
m_view->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
m_view->setEditTriggers(QAbstractItemView::NoEditTriggers);
@@ -378,7 +401,7 @@ void ElementPickerPopup::setCommands(const QStringList &ids)
auto *button = new QToolButton(m_commands);
button->setAutoRaise(true);
button->setIconSize(QSize(24, 24));
button->setIcon(location.icon());
button->setIcon(elementIcon(location.icon(), QSize(24, 24), this));
button->setToolTip(location.name());
button->setFocusPolicy(Qt::NoFocus);
connect(button, &QToolButton::clicked, this, [this, location]() {
@@ -542,7 +565,7 @@ QListWidgetItem *ElementPickerPopup::barItem(const QString &id, bool icon_only)
const ElementsLocation location(id);
if (location.exist()) {
text = location.name();
icon = location.icon();
icon = elementIcon(location.icon(), QSize(24, 24), this);
}
} else if (QAction *action = commandAction(id)) {
text = action->text().remove(QLatin1Char('&'));
@@ -632,7 +655,8 @@ void ElementPickerPopup::runSymbolSearch()
if (ElementsLocation(hit.path).isProject()) {
continue;
}
auto *item = new QListWidgetItem(hit.icon, hit.name);
auto *item = new QListWidgetItem(
elementIcon(hit.icon, m_edit_symbols->iconSize(), this), hit.name);
item->setData(Qt::UserRole, hit.path);
item->setToolTip(QStringLiteral("%1\n%2").arg(hit.name, hit.folder));
m_edit_symbols->addItem(item);
@@ -27,6 +27,7 @@
#include "../qetmessagebox.h"
#include "../qetproject.h"
#include "elementcollectionitem.h"
#include "elementpreviewdelegate.h"
#include "elementscollectionmodel.h"
#include "elementslocation.h"
#include "elementstreeview.h"
@@ -258,6 +259,8 @@ void ElementsCollectionWidget::setUpWidget()
m_search_model = new QStandardItemModel(this);
m_search_results = new SearchResultsView(this);
m_search_results->setModel(m_search_model);
//Same dark-palette icons as the tree the list stands in for
m_search_results->setItemDelegate(new ElementPreviewDelegate(m_search_results));
m_search_results->setDragDropMode(QAbstractItemView::DragOnly);
m_search_results->setIconSize(QSize(50, 50));
m_search_results->setUniformItemSizes(false);
+18
View File
@@ -54,6 +54,24 @@ namespace Alignment
QPointF ref;
};
/**
@return @a items taken as one piece, the way a group lines up:
their edges together, and the middle of that box as its centre.
A single item keeps its own centre.
*/
inline Item combined(const QList<Item> &items)
{
Item result;
for (const Item &item : items)
result.edges = result.edges.isNull() ? item.edges
: result.edges.united(item.edges);
if (items.size() == 1)
result.ref = items.first().ref;
else
result.ref = result.edges.center();
return result;
}
/**
@return true if aligning on @a edge moves items along x
*/
+21
View File
@@ -2891,6 +2891,27 @@ int QetScriptApi::imageIndex(int folioIndex, const QString &uuid) const
return indexOfUuid(sortedImages(folioIndex), uuid);
}
/// @brief QetScriptApi::tableIndex as textIndex(), for tables(folioIndex).
int QetScriptApi::tableIndex(int folioIndex, const QString &uuid) const
{
return indexOfUuid(sortedTables(folioIndex), uuid);
}
/**
@brief QetScriptApi::elementTextIndex
The current index in elementTexts() of the text field carrying
@p textUuid, on the element @p elementUuid, or -1.
A field's uuid is unique only within its element: copying an element
keeps its fields' uuids, so the element is part of the address.
*/
int QetScriptApi::elementTextIndex(int folioIndex, const QString &elementUuid,
const QString &textUuid) const
{
Element *element = findElement(folioIndex, elementUuid);
if (!element) return -1;
return indexOfUuid(element->dynamicTextItems(), textUuid);
}
/**
@brief QetScriptApi::addImage
Place a picture from a file, as the "add image" tool does after its file
+3
View File
@@ -483,6 +483,7 @@ class QetScriptApi : public QObject
// -- a BOM/nomenclature or summary table placed on a folio --
Q_INVOKABLE QStringList tables(int folioIndex) const;
Q_INVOKABLE int tableIndex(int folioIndex, const QString &uuid) const;
Q_INVOKABLE int addTable(int folioIndex, const QString &kind, const QString &name,
const QString &query);
Q_INVOKABLE bool deleteTable(int folioIndex, int tableIndex);
@@ -508,6 +509,8 @@ class QetScriptApi : public QObject
// -- the text fields shown on a symbol (label, terminal names, ...) --
Q_INVOKABLE QStringList elementTexts(int folioIndex, const QString &elementUuid) const;
Q_INVOKABLE int elementTextIndex(int folioIndex, const QString &elementUuid,
const QString &textUuid) const;
Q_INVOKABLE int addElementText(int folioIndex, const QString &elementUuid,
const QString &source, const QString &value,
double x, double y);
+7 -13
View File
@@ -97,12 +97,14 @@ AlignSelectionCommand::AlignSelectionCommand(Diagram *diagram, Mode mode, QUndoC
//group come along; shapes outside one are left out, as above.
struct Unit {
QList<QGraphicsObject *> members;
Alignment::Item geometry;
QList<Alignment::Item> parts;
QGraphicsObject *snap_item = nullptr; ///< lands on its grid
qreal divisor = 1;
};
QList<Unit> units;
QHash<QUuid, int> group_units;
//Returns a reference into units, which a later call can grow:
//use it before calling again, never keep it
auto unitFor = [&](QGraphicsObject *item) -> Unit &
{
const QUuid group = ItemGroups::groupOf(item);
@@ -120,10 +122,7 @@ AlignSelectionCommand::AlignSelectionCommand(Diagram *diagram, Mode mode, QUndoC
{
Unit &unit = unitFor(entry.item);
unit.members << entry.item;
unit.geometry.edges = unit.geometry.edges.isNull()
? entry.geometry.edges
: unit.geometry.edges.united(entry.geometry.edges);
unit.geometry.ref = entry.geometry.ref;
unit.parts << entry.geometry;
//Symbols come first in entries, so a group with one snaps on it
if (!unit.snap_item) {
unit.snap_item = entry.item;
@@ -136,16 +135,11 @@ AlignSelectionCommand::AlignSelectionCommand(Diagram *diagram, Mode mode, QUndoC
continue;
Unit &unit = unitFor(shape);
unit.members << shape;
unit.geometry.edges = unit.geometry.edges.isNull()
? shape->sceneBoundingRect()
: unit.geometry.edges.united(shape->sceneBoundingRect());
const QRectF rect = shape->sceneBoundingRect();
unit.parts << Alignment::Item{rect, rect.center()};
if (!unit.snap_item)
unit.snap_item = shape;
}
for (Unit &unit : units) {
if (unit.members.size() > 1)
unit.geometry.ref = unit.geometry.edges.center();
}
m_item_count = units.size();
//Lining up a single item on itself would only snap it
@@ -165,7 +159,7 @@ AlignSelectionCommand::AlignSelectionCommand(Diagram *diagram, Mode mode, QUndoC
QList<Alignment::Item> geometry;
for (const Unit &unit : std::as_const(units))
geometry << unit.geometry;
geometry << Alignment::combined(unit.parts);
const QList<QPointF> offsets = Alignment::alignOffsets(geometry, edge);
for (int i = 0 ; i < units.size() ; ++i)
+27
View File
@@ -155,6 +155,33 @@ private slots:
QCOMPARE(offset.y(), 0.0);
}
// A group lines up on the middle of its members' box; a unit with a
// single member, such as a shape whose group-mate is locked, keeps
// that member's own centre instead of the origin of the folio.
void combinedUnits()
{
const Alignment::Item a{QRectF(100, 200, 40, 20), QPointF(110, 210)};
const Alignment::Item b{QRectF(300, 260, 20, 60), QPointF(310, 270)};
const Alignment::Item one = Alignment::combined({a});
QCOMPARE(one.edges, a.edges);
QCOMPARE(one.ref, a.ref);
const Alignment::Item both = Alignment::combined({a, b});
QCOMPARE(both.edges, QRectF(100, 200, 220, 120));
QCOMPARE(both.ref, QPointF(210, 260));
// a lone shape centred on x = 200 and a symbol at x = 400 meet
// half way, at 300; with the folio origin as the shape's centre
// they would meet at 200 and the shape would move 200 px
const QRectF shape(180, 50, 40, 40);
const QList<QPointF> offsets = Alignment::alignOffsets(
{Alignment::combined({{shape, shape.center()}}), {QRectF(390, 0, 20, 20), QPointF(400, 10)}},
Alignment::HCenter);
QCOMPARE(offsets.at(0), QPointF(100, 0));
QCOMPARE(offsets.at(1), QPointF(-100, 0));
}
void emptySelection()
{
QVERIFY(Alignment::alignOffsets({}, Alignment::Left).isEmpty());