diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 1328c8ecc..f07479395 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -707,6 +707,399 @@ 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( + '' + '' + f'' + '' + '' + '' + 'K1' + ' spaced ' + 'no name, ignored' + '' + f'' + f'' + '' + '' + f'' + '' + '' + f'' + '' + f'' + '') + + def tearDown(self): + self.tmp.cleanup() + + def test_project_info_exact(self): + self.assertEqual(m.tool_project_info(self.qet), { + "file": self.qet, "title": "T", "version": "0.100", + "folio_count": 2, "element_count": 4, "conductor_count": 3, + "folios": [{"index": 1, "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'' for i in range(201)) + wires = "".join(f'' for i in range(201)) + big = Path(self.tmp.name) / "big.qet" + big.write_text(f'{els}' + f'{wires}') + for tool, key in ((m.tool_elements, "elements"), (m.tool_conductors, "conductors")): + with self.subTest(tool=key): + r = tool(str(big)) + self.assertEqual((r["count"], r["truncated"], len(r[key])), (201, True, 200)) + + def test_conductor_row_without_an_index(self): + c = ET.fromstring('') + self.assertEqual(m._conductor_row(3, c)["key"], "3:#7--#8") + + def test_plain_text(self): + self.assertEqual(m._plain_text("

a & b

\n

c

"), + "a & b c") + # no : the whole string is the text + self.assertEqual(m._plain_text("plain x"), "plain x") + self.assertEqual(m._plain_text(None), "") + + +class DiffContracts(unittest.TestCase): + """Exact qet_diff output, section by section, on hand-made pairs. + + The mutation audit found most of tool_diff's output unchecked: a dropped + field of a move, of a conductor change or of a text record, a skipped + comparison, or a list cap off by one all passed the suite.""" + + A = "{aaaaaaaa-0000-4000-8000-000000000001}" + B = "{bbbbbbbb-0000-4000-8000-000000000002}" + C = "{cccccccc-0000-4000-8000-000000000003}" + D = "{dddddddd-0000-4000-8000-000000000004}" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.n = 0 + + def tearDown(self): + self.tmp.cleanup() + + def qet(self, body, title="T"): + self.n += 1 + p = Path(self.tmp.name) / f"p{self.n}.qet" + p.write_text(f'{body}') + return str(p) + + @staticmethod + def el(uuid, x, y, label=None, comment=None, type_="x/coil.elmt", texts=""): + info = "".join(f'{v}' + for n, v in (("label", label), ("comment", comment)) if v is not None) + u = f' uuid="{uuid}"' if uuid else "" + return (f'' + f'{info}' + f'{texts}
') + + @staticmethod + def wire(e1, e2, **attrs): + a = "".join(f' {k}="{v}"' for k, v in attrs.items()) + return f'' + + def folio(self, elements="", wires="", extra="", **attrs): + a = "".join(f' {k}="{v}"' for k, v in attrs.items()) + return (f'{elements}' + f'{wires}{extra}') + + def test_elements_section_exact(self): + A, B, C, D = self.A, self.B, self.C, self.D + before = self.qet(self.folio( + self.el(A, 10, 20, "K1", "c") + self.el(B, 0, 0) + self.el(C, 5, 5) + + self.el("", 1, 1, type_="x/old.elmt"))) + after = self.qet(self.folio( + self.el(A, 15, 20, "K2", "d") + self.el(B, 0, 0) + self.el(D, 7, 7) + + self.el("", 2, 2, type_="x/old.elmt"))) + self.assertEqual(m.tool_diff(before, after)["elements"], { + "before": 4, "after": 4, + # no uuid: keyed on folio, position and name, so a move is remove + add + "added": sorted([D, "1:2,2:old"]), "removed": sorted([C, "1:1,1:old"]), + "moved": [{"uuid": A, "name": "coil", "folio": 1, "from": ["10", "20"], + "to": ["15", "20"], "delta": [5.0, 0.0]}], + "moved_count": 1, "distinct_move_deltas": [[5.0, 0.0]], + "relabelled": [{"uuid": A, "name": "coil", "from": "K1", "to": "K2"}], + "info_changed": [{"uuid": A, "name": "coil", "from": {"label": "K1", "comment": "c"}, + "to": {"label": "K2", "comment": "d"}}]}) + + 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"]), + ([], [], [], [], [])) + + 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, "added": [k(A, C)], "removed": [k(B, C)], + "changed": [{"key": k(A, B), "changed": {"num": ["W1", "W9"], "color": ["", "red"]}}], + "changed_count": 1}) + + def test_unresolvable_conductor_ends_are_flagged(self): + p = self.qet(self.folio(self.el("", 0, 0), + '')) + c = m.tool_diff(p, p)["conductors"] + self.assertEqual(c["unstable_keys"], 1) + self.assertIn("renumbers on save", c["warning"]) + q = self.qet(self.folio(self.el(self.A, 0, 0) + self.el(self.B, 0, 0), + self.wire(self.A, self.B))) + self.assertNotIn("unstable_keys", m.tool_diff(q, q)["conductors"]) + + def test_list_caps(self): + many = lambda n, x: "".join(self.el(f"{{{i:08d}-0000-4000-8000-000000000000}}", x, 0) + for i in range(n)) + e = m.tool_diff(self.qet(self.folio(many(101, 0))), self.qet(self.folio(many(101, 5))))["elements"] + self.assertEqual((len(e["moved"]), e["moved_count"]), (100, 101)) + e = m.tool_diff(self.qet(self.folio(many(51, 0))), self.qet(self.folio("")))["elements"] + self.assertEqual((len(e["removed"]), e["before"]), (50, 51)) + e = m.tool_diff(self.qet(self.folio("")), self.qet(self.folio(many(51, 0))))["elements"] + self.assertEqual(len(e["added"]), 50) + + def test_texts_shapes_images_by_uuid(self): + def extras(x, color, uuid=True): + u = lambda s: f' uuid="{{{s}}}"' if uuid else "" + return (f'' + f'' + f'' + f'') + d = m.tool_diff(self.qet(self.folio(extra=extras(1, "#000"))), + self.qet(self.folio(extra=extras(4, "#f00")))) + # A uuid-keyed item is labelled as it is after the edit; "changed" has both. + self.assertEqual(d["texts"]["changed"], [{ + "item": {"folio": 1, "x": "4", "y": "2", "text": "note", "uuid": "{t}"}, + "changed": {"x": ["1", "4"], "color": ["#000", "#f00"]}}]) + self.assertEqual(d["shapes"]["changed"], [{ + "item": {"folio": 1, "type": "Rectangle", "from": ["4", "0"], "to": ["9", "9"], "uuid": "{s}"}, + "changed": {"from": [["1", "0"], ["4", "0"]], "line_color": ["#000", "#f00"]}}]) + self.assertEqual(d["images"]["changed"], [{ + "item": {"folio": 1, "x": "4", "y": "3", "uuid": "{i}"}, "changed": {"x": ["1", "4"]}}]) + for kind in ("texts", "shapes", "images"): + self.assertEqual((d[kind]["keyed_by"], d[kind]["before"], d[kind]["after"]), ("uuid", 1, 1)) + # without uuids a move is a removal plus an addition, keyed by position + d = m.tool_diff(self.qet(self.folio(extra=extras(1, "#000", uuid=False))), + self.qet(self.folio(extra=extras(4, "#000", uuid=False)))) + self.assertEqual((d["images"]["keyed_by"], len(d["images"]["added"]), len(d["images"]["removed"])), + ("position", 1, 1)) + + def test_shape_style_fields(self): + def shape(style, width, brush): + return (f'' + f'{brush}') + d = m.tool_diff( + self.qet(self.folio(extra=shape("SolidLine", 1, ''))), + self.qet(self.folio(extra=shape("DashLine", 2, '')))) + self.assertEqual(d["shapes"]["changed"][0]["changed"], + {"line_style": ["SolidLine", "DashLine"], "line_width": ["1", "2"], + "fill": ["none", "#0f0"]}) + + def test_element_text_fields(self): + def field(x, size, shows): + return (f'{shows}' + 'label') + d = m.tool_diff(self.qet(self.folio(self.el(self.A, 0, 0, texts=field(3, 9, "K1")))), + self.qet(self.folio(self.el(self.A, 0, 0, texts=field(6, 9, "K2"))))) + self.assertEqual(d["element_texts"]["changed"], [{ + "item": {"element": self.A, "source": "ElementInfo", "bound_to": "label", "n": 1}, + "changed": {"x": ["3", "6"], "shows": ["K1", "K2"]}}]) + + def test_folio_and_project_fields(self): + d = m.tool_diff(self.qet(self.folio(title="A", cols="10"), title="P"), + self.qet(self.folio(title="B", cols="10"), title="Q")) + self.assertEqual(d["project"], {"changed": {"title": ["P", "Q"]}}) + self.assertEqual(d["folios"], {"before": 1, "after": 1, + "changed": [{"folio": 1, "changed": {"title": ["A", "B"]}}]}) + d = m.tool_diff(self.qet(self.folio(title="A")), + self.qet(self.folio(title="B") + self.folio(title="C"))) + self.assertIn("folio count changed", d["folios"]["note"]) + d = m.tool_diff(self.qet(self.folio(title="A")), + self.qet(self.folio(title="A") + self.folio(title="C"))) + self.assertNotIn("note", d["folios"]) + + def test_every_compared_field_is_reported_on_its_own(self): + """Change one attribute at a time; each must show as exactly that field.""" + def changed_fields(before, after, kind): + d = m.tool_diff(self.qet(self.folio(extra=before)), self.qet(self.folio(extra=after))) + return [list(c["changed"]) for c in d[kind]["changed"]] + def text(**v): + a = {"rotation": "0", "font": "f", "color": "c", **v} + return ('') + def shape(rot): + return (f'') + def image(**v): + a = {"size": "1", "rotation": "0", **v} + return ('') + for kind, before, after, field in ( + ("texts", text(), text(rotation="90"), "rotation"), + ("texts", text(), text(font="g"), "font"), + ("shapes", shape(0), shape(90), "rotation"), + ("images", image(), image(rotation="90"), "rotation"), + ("images", image(), image(size="2"), "scale")): + with self.subTest(kind=kind, field=field): + self.assertEqual(changed_fields(before, after, kind), [[field]]) + + def test_every_element_text_field_attribute(self): + def field(**v): + a = {"x": "0", "y": "0", "frame": "false", "rotation": "0", "text_width": "-1", + "font": "Sans,9,-1", **v} + return self.el(self.A, 0, 0, texts=( + "K1' + '%{label}')) + for attr, value, reported in (("y", "5", "y"), ("rotation", "90", "rotation"), + ("text_width", "40", "width"), ("frame", "true", "frame"), + ("font", "Sans,12,-1", "size")): + with self.subTest(attr=attr): + d = m.tool_diff(self.qet(self.folio(field())), + self.qet(self.folio(field(**{attr: value})))) + ch = d["element_texts"]["changed"] + self.assertEqual([list(c["changed"]) for c in ch], [[reported]]) + # a composite field is identified by its formula + self.assertEqual(ch[0]["item"]["bound_to"], "%{label}") + + def test_an_unchanged_conductor_is_not_listed_as_changed(self): + A, B, C = self.A, self.B, self.C + els = self.el(A, 0, 0) + self.el(B, 0, 0) + self.el(C, 0, 0) + before = self.qet(self.folio(els, self.wire(A, B, num="W1") + self.wire(B, C, num="W2"))) + after = self.qet(self.folio(els, self.wire(A, B, num="W1") + self.wire(B, C, num="W5"))) + c = m.tool_diff(before, after)["conductors"] + self.assertEqual([x["changed"] for x in c["changed"]], [{"num": ["W2", "W5"]}]) + + def test_a_strip_without_its_data_is_skipped(self): + p = self.qet(self.folio() + '') + self.assertEqual(m.tool_diff(p, p)["terminal_strips"]["before"], 0) + + def test_every_list_is_capped(self): + U = lambda i: f"{{{i:08d}-0000-4000-8000-000000000000}}" + many = lambda n, label: "".join(self.el(U(i), 0, 0, label) for i in range(n)) + e = m.tool_diff(self.qet(self.folio(many(51, "a"))), + self.qet(self.folio(many(51, "b"))))["elements"] + self.assertEqual((len(e["relabelled"]), len(e["info_changed"])), (50, 50)) + els = many(102, None) + wires = lambda n, num: "".join(self.wire(U(i), U(i + 1), num=num) for i in range(n)) + c = m.tool_diff(self.qet(self.folio(els, wires(101, "a"))), + self.qet(self.folio(els, wires(101, "b"))))["conductors"] + self.assertEqual((len(c["changed"]), c["changed_count"]), (100, 101)) + c = m.tool_diff(self.qet(self.folio(els, wires(51, "a"))), self.qet(self.folio(els)))["conductors"] + self.assertEqual(len(c["removed"]), 50) + c = m.tool_diff(self.qet(self.folio(els)), self.qet(self.folio(els, wires(51, "a"))))["conductors"] + self.assertEqual(len(c["added"]), 50) + folios = lambda t: "".join(self.folio(title=f"{t}{i}") for i in range(51)) + f = m.tool_diff(self.qet(folios("a")), self.qet(folios("b")))["folios"] + self.assertEqual(len(f["changed"]), 50) + # _diff_keyed's own caps, through the text records + texts = lambda n, x: "" + "".join( + f'' for i in range(n)) + "" + t = m.tool_diff(self.qet(self.folio(extra=texts(51, 0))), + self.qet(self.folio(extra=texts(51, 1))))["texts"] + self.assertEqual(len(t["changed"]), 50) + t = m.tool_diff(self.qet(self.folio(extra=texts(51, 0))), self.qet(self.folio()))["texts"] + self.assertEqual(len(t["removed"]), 50) + t = m.tool_diff(self.qet(self.folio()), self.qet(self.folio(extra=texts(51, 0))))["texts"] + self.assertEqual(len(t["added"]), 50) + + def test_terminal_strips(self): + def strip(name, n): + terms = "".join(f'' for _ in range(n)) + return ('' + 'I' + 'L' + f'{name}' + f'{terms}') + d = m.tool_diff(self.qet(self.folio() + strip("X1", 2)), self.qet(self.folio() + strip("X1", 3))) + self.assertEqual(d["terminal_strips"]["changed"], + [{"item": "I L X1", "changed": {"terminals": [2, 3]}}]) + + class Diff(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory()