mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-28 21:34:12 +02:00
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:
+1582
-925
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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",)},
|
||||
|
||||
@@ -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 & <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="<html><body>note</body></html>"/></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="<html><body>note</body></html>"/></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);
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user