diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 6c98569d8..e4b53cafb 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -468,6 +468,80 @@ class EditValidation(unittest.TestCase): need = json.loads(re.search(r"var need = (\[.*?\]);", script).group(1)) self.assertFalse({"shapeIndex", "textIndex", "imageIndex"} & set(need)) + def test_malformed_arguments_are_refused(self): + """Each argument kind rejects what it cannot take, before any launch. + Found by the mutation audit: forcing any of these checks off went + unnoticed.""" + f = {"op": "add_folio", "id": "f"} + cases = [ + ("indices", {"op": "group_terminals", "strip": 0, "indices": []}), + ("indices", {"op": "group_terminals", "strip": 0, "indices": [1, "2"]}), + ("indices", {"op": "group_terminals", "strip": 0, "indices": [True]}), + ("indices", {"op": "bridge_terminals", "strip": 0, "indices": 3}), + ("true or false", {"op": "add_polygon", "folio": 0, "closed": 1, + "points": [{"x": 0, "y": 0}, {"x": 1, "y": 1}]}), + ("at least 2", {"op": "add_polygon", "folio": 0, "closed": True, + "points": [{"x": 0, "y": 0}]}), + ("at least 2", {"op": "add_polygon", "folio": 0, "closed": True, "points": "xy"}), + ("entries must be", {"op": "add_polygon", "folio": 0, "closed": True, + "points": [{"x": 0, "y": 0}, {"x": "1", "y": 1}]}), + ("entries must be", {"op": "add_polygon", "folio": 0, "closed": True, + "points": [{"x": 0, "y": 0}, {"x": True, "y": 1}]}), + ("entries must be", {"op": "add_polygon", "folio": 0, "closed": True, + "points": [{"x": 0, "y": 0}, [1, 1]]}), + ("at least 2 nodes", {"op": "add_path", "folio": 0, "closed": False, + "nodes": [{"x": 0, "y": 0}]}), + ("corner, smooth or symmetric", {"op": "add_path", "folio": 0, "closed": False, + "nodes": [{"x": 0, "y": 0}, + {"x": 1, "y": 1, "kind": "sharp"}]}), + ("inHandle", {"op": "add_path", "folio": 0, "closed": False, + "nodes": [{"x": 0, "y": 0}, {"x": 1, "y": 1, "inHandle": [0, 0]}]}), + ("outHandle", {"op": "add_path", "folio": 0, "closed": False, + "nodes": [{"x": 0, "y": 0}, {"x": 1, "y": 1, "outHandle": {"x": 0}}]}), + ("unknown kind", {"op": "search_and_replace", "kind": "folio", "field": "x", + "pattern": "a", "replacement": "b", "regex": False, + "case_sensitive": False}), + ("unknown conductor field", {"op": "search_and_replace", "kind": "conductor", + "field": "label", "pattern": "a", "replacement": "b", + "regex": False, "case_sensitive": False}), + ("non-empty", {"op": "search_and_replace", "kind": "element_info", "field": "", + "pattern": "a", "replacement": "b", "regex": False, + "case_sensitive": False}), + ("not both", {"op": "delete_conductor", "folio": 0, + "conductor": "{11111111-2222-4333-8444-555555555555}", + "element": "{11111111-2222-4333-8444-555555555555}"}), + ("not both", {"op": "delete_conductor", "folio": 0, + "conductor": "{11111111-2222-4333-8444-555555555555}", "terminal": 0}), + ("not an object", "add_folio"), + ] + for message, op in cases: + with self.subTest(op=op): + with self.assertRaisesRegex(ValueError, message): + self.build([f, op]) + + def test_valid_shapes_of_those_arguments_pass(self): + """The other side: the same kinds accept what they should, so the + refusals above are not everything failing.""" + s = self.build([ + {"op": "add_polygon", "folio": 0, "closed": False, + "points": [{"x": 0, "y": 0}, {"x": 1.5, "y": -2}]}, + {"op": "add_path", "folio": 0, "closed": True, + "nodes": [{"x": 0, "y": 0, "kind": "smooth", "inHandle": {"x": 1, "y": 1}}, + {"x": 5, "y": 5, "outHandle": {"x": 2, "y": 2}}]}, + {"op": "add_polygon", "folio": 0, "closed": True, + "points": [{"x": 0, "y": 0, "kind": "anything"}, {"x": 1, "y": 1}]}, + {"op": "search_and_replace", "kind": "conductor", "field": "num", "pattern": "a", + "replacement": "b", "regex": True, "case_sensitive": False}, + {"op": "search_and_replace", "kind": "text", "field": "", "pattern": "a", + "replacement": "b", "regex": False, "case_sensitive": True}]) + self.assertIn("qet.addPolygon(0, [{", s) + self.assertIn("qet.searchAndReplace(\"conductor\", \"num\", \"a\", \"b\", true, false)", s) + self.assertIn("qet.searchAndReplace(\"text\", \"\", \"a\", \"b\", false, true)", s) + + def test_no_id_stores_nothing(self): + s = self.build([{"op": "add_folio"}]) + self.assertNotIn("R[", s.split("if (missing.length === 0) {")[1]) + def test_a_string_index_that_is_not_a_uuid_is_refused(self): for bad in ("3", "{nope}", "11111111-1111"): with self.subTest(index=bad): @@ -576,6 +650,37 @@ class ResultParsing(unittest.TestCase): self.assertEqual(parsed["operations"], []) + def test_capabilities_notes_and_save(self): + """Every field the script reports reaches the result, attached where + it belongs. Found by the mutation audit: only the binary tests read + these back.""" + text = "\n".join([ + self.line(kind="capabilities", missing=["addFolio"]), + self.line(kind="op", index=0, op="a", id=None, result=True), + self.line(kind="op", index=1, op="b", id=None, result=False), + self.line(kind="op_note", index=1, note="why"), + self.line(kind="save", result=True, stopped_early=True), + self.line(kind="something_newer", result=False)]) # not a save + parsed = m._parse_script_output(text) + self.assertEqual(set(parsed), {"missing_methods", "operations", "saved", "stopped_early"}) + self.assertEqual(parsed["missing_methods"], ["addFolio"]) + self.assertTrue(parsed["saved"]) + self.assertTrue(parsed["stopped_early"]) + self.assertNotIn("note", parsed["operations"][0]) + self.assertEqual(parsed["operations"][1]["note"], "why") + # no capabilities line: unknown, not "nothing missing" + self.assertIsNone(m._parse_script_output(self.line(kind="save", result=False))["missing_methods"]) + self.assertEqual(m._parse_script_output(self.line(kind="capabilities", missing=None)) + ["missing_methods"], []) + + def test_a_line_without_the_marker_is_ignored(self): + """Even one that would parse if read from where the marker would be.""" + almost = "x" * (len(m._MARKER) - 1) + json.dumps({"kind": "save", "result": True}) + parsed = m._parse_script_output(almost) + self.assertIsNone(parsed["saved"]) + self.assertFalse(parsed["stopped_early"]) + + class TerminalOrder(unittest.TestCase): """QElectroTech indexes terminals top-to-bottom then left-to-right, not in file order. Getting it wrong wires the wrong end of a coil with no error; @@ -972,6 +1077,32 @@ class ReadToolContracts(unittest.TestCase): self.assertEqual(numbered, "1:{E}@0,-4,2--{E}@6,0,1") self.assertEqual(by_uuid, numbered) + def test_conductor_key_same_in_both_forms_in_a_column(self): + """Terminals one above the other share their x: a uuid end must be + matched on x, y and orientation together, not on any one of them. + Found by the mutation audit: an "or" in place of "and" went + unnoticed while every test's terminals differed in every coordinate.""" + root = ET.fromstring( + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '') + numbered, by_uuid = [m._conductor_row(i, c, ix)["key"] + for i, c, ix in m._conductors(root)] + self.assertEqual(numbered, "1:{E}@0,24,0--{E}@30,24,0") + self.assertEqual(by_uuid, numbered) + def test_conductor_row_without_an_index(self): c = ET.fromstring('') self.assertEqual(m._conductor_row(3, c)["key"], "3:#7--#8")