mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-29 22:24:13 +02:00
Merge pull request #1100 from ispyisail/feat/qet-mcp-items
Add qet_items: list drawn items with their uuids
This commit is contained in:
@@ -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 |
|
||||
|
||||
+171
-35
@@ -279,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:
|
||||
@@ -343,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()),
|
||||
@@ -351,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", "")}))
|
||||
for t in d.iter("input"):
|
||||
texts.append(record(
|
||||
t, {"folio": n, "x": t.get("x", ""), "y": t.get("y", ""),
|
||||
@@ -374,7 +410,7 @@ def _extras(root: ET.Element) -> dict:
|
||||
im, {"folio": n, "x": im.get("x", ""), "y": im.get("y", "")},
|
||||
{"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", "")
|
||||
@@ -387,6 +423,8 @@ 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": _angle(t.get("rotation", "")),
|
||||
@@ -404,8 +442,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:
|
||||
@@ -431,7 +479,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"])
|
||||
@@ -447,34 +495,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())(
|
||||
@@ -531,15 +632,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 "
|
||||
@@ -556,8 +663,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 {
|
||||
@@ -574,8 +684,9 @@ def tool_diff(before: str, after: str) -> dict:
|
||||
},
|
||||
"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,
|
||||
@@ -2203,6 +2314,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 "
|
||||
@@ -2251,8 +2382,12 @@ TOOLS = [
|
||||
"description": "Structurally diff two .qet files: which elements moved and "
|
||||
"by what delta, which were rotated (orientation in quarter "
|
||||
"turns, 0-3), 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.",
|
||||
"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": {
|
||||
@@ -2783,6 +2918,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):
|
||||
@@ -865,6 +865,69 @@ class ReadToolContracts(unittest.TestCase):
|
||||
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.
|
||||
|
||||
@@ -969,7 +1032,7 @@ class DiffContracts(unittest.TestCase):
|
||||
+ 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, "added": [k(A, C)], "removed": [k(B, C)],
|
||||
"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})
|
||||
|
||||
@@ -1046,7 +1109,7 @@ class DiffContracts(unittest.TestCase):
|
||||
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,
|
||||
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")))
|
||||
@@ -1144,6 +1207,162 @@ class DiffContracts(unittest.TestCase):
|
||||
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))
|
||||
@@ -1533,6 +1752,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():
|
||||
|
||||
Reference in New Issue
Block a user