"""Native command regressions for sketch cancellation and assembly drawings.""" import json import math import traceback from pathlib import Path import FreeCAD as App import FreeCADGui as Gui from PySide import QtCore, QtGui try: from PySide import QtWidgets except ImportError: QtWidgets = QtGui try: from PySide import QtTest except ImportError: try: from PySide6 import QtTest except ImportError: from PySide2 import QtTest def run(output_dir): import qi_commands as commands import qi_model as model import qi_ui as ui out = Path(output_dir).absolute() out.mkdir(parents=True, exist_ok=True) initial_docs = set(App.listDocuments()) initial_active = App.ActiveDocument.Name if App.ActiveDocument else None report = {"status": "running", "passed": 0, "failed": 0, "checks": [], "artifacts": []} main = Gui.getMainWindow() commands.register_commands() def settle(ms=100): QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(ms) def capture(name): settle() path = out / (name + ".png") assert main.grab().save(str(path)), "Cannot save actual GUI evidence: " + str(path) report["artifacts"].append(str(path)) def check(name, fn): try: evidence = fn() report["checks"].append({"name": name, "passed": True, "evidence": evidence}) report["passed"] += 1 except Exception: report["checks"].append({"name": name, "passed": False, "error": traceback.format_exc()}) report["failed"] += 1 (out / "command-workflows-report.json").write_text( json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") def document_state(doc): return (tuple(obj.Name for obj in doc.Objects), doc.UndoCount, doc.RedoCount, bool(Gui.getDocument(doc.Name).Modified)) def cancel_plane_without_document(): Gui.Selection.clearSelection() App.setActiveDocument("") assert App.ActiveDocument is None, "Fixture did not deactivate the current document" before = set(App.listDocuments()) states = {name: document_state(doc) for name, doc in App.listDocuments().items()} evidence = {"cancel_clicked": False, "dialog_seen": False} errors = [] attempts = [0] timer = QtCore.QTimer(main) def cancel_visible_plane_dialog(): attempts[0] += 1 dialogs = [widget for widget in QtWidgets.QApplication.topLevelWidgets() if isinstance(widget, QtWidgets.QDialog) and widget.isVisible() and widget.windowTitle() == "Плоскость эскиза"] if dialogs: dialog = dialogs[0] evidence["dialog_seen"] = True try: path = out / "command-plane-cancel-dialog.png" assert dialog.grab().save(str(path)), "Plane dialog screenshot was not saved" report["artifacts"].append(str(path)) box = dialog.findChild(QtWidgets.QDialogButtonBox) button = box.button(QtWidgets.QDialogButtonBox.Cancel) if box else None assert button and button.isVisible(), "The actual plane Cancel button is missing" QtTest.QTest.mouseClick(button, QtCore.Qt.LeftButton) evidence["cancel_clicked"] = True except Exception: errors.append(traceback.format_exc()) dialog.reject() timer.stop() elif attempts[0] >= 100: errors.append("Plane chooser did not appear within five seconds") modal = QtWidgets.QApplication.activeModalWidget() if isinstance(modal, QtWidgets.QDialog): modal.reject() timer.stop() timer.timeout.connect(cancel_visible_plane_dialog) timer.start(50) try: # Exercise the real command and modal chooser; no chooser stub. Gui.runCommand("QI_NewSketch", 0) finally: timer.stop() timer.deleteLater() settle() assert not errors, errors assert evidence["dialog_seen"] and evidence["cancel_clicked"], evidence assert set(App.listDocuments()) == before, "Cancelling the plane chooser created a project" assert App.ActiveDocument is None, "Cancelling the plane chooser activated a document" assert states == {name: document_state(doc) for name, doc in App.listDocuments().items()}, \ "Cancelling the plane chooser modified an existing document or its history" return dict(evidence, documents_preserved=True, no_active_document=True) def projection_signature(page): model.prepare_drawing(page) signature = {} for view in page.Views: edges = view.getVisibleEdges() assert len(edges) >= 4, "Native drawing projection contains no usable geometry: " + view.Name assert view.getStatusString() == "Valid", view.getStatusString() signature[view.Name] = sorted([ [round(float(edge.Length), 6), round(float(edge.BoundBox.XMin), 6), round(float(edge.BoundBox.XMax), 6), round(float(edge.BoundBox.YMin), 6), round(float(edge.BoundBox.YMax), 6)] for edge in edges]) return signature def assert_sources(page, links): assert len(page.Views) == 3, "Drawing did not contain three associative views" expected = {link.Name for link in links} assembly = page.Document.getObject("Assembly") assert assembly.TypeId == "Assembly::AssemblyObject", "Drawing lost its native assembly" actual = [obj for obj in assembly.Group if obj.isDerivedFrom("App::Link")] assert {obj.Name for obj in actual} == expected, "Native assembly components were lost" assert all(obj.getLinkedObject() is not None for obj in actual), "Linked definitions were lost" for view in page.Views: assert view.Source == [assembly], "Drawing must follow the live native assembly membership" drawing_snapshots = {} def snapshot_drawing(stage, page, signature): def placement_values(placement): return {"base": list(placement.Base), "rotation_quaternion": list(placement.Rotation.Q)} def shape_values(shape): box = shape.BoundBox return {"volume_mm3": shape.Volume, "valid": shape.isValid(), "solids": len(shape.Solids), "bounds_mm": [box.XMin, box.YMin, box.ZMin, box.XMax, box.YMax, box.ZMax]} sources = {} views = {} for view in page.Views: edges = view.getVisibleEdges() views[view.Name] = { "state": list(view.State), "status": view.getStatusString(), "source_names": [obj.Name for obj in view.Source], "direction": list(view.Direction), "scale": float(view.Scale), "scale_type": str(view.ScaleType), "raw_visible_edge_values": [ [float(edge.Length), edge.BoundBox.XMin, edge.BoundBox.XMax, edge.BoundBox.YMin, edge.BoundBox.YMax] for edge in edges], } for source in view.Source: if source.Name in sources: continue definition = source.getLinkedObject() if source.isDerivedFrom("App::Link") else source parameters = {} for name in ("QILength", "QIWidth", "QIThickness", "QIRadius", "QIHeight"): if hasattr(definition, name): value = getattr(definition, name) parameters[name] = float(value.Value if hasattr(value, "Value") else value) sources[source.Name] = { "type": source.TypeId, "definition_name": definition.Name, "shape": shape_values(source.Shape), "definition_shape": shape_values(definition.Shape), "placement": placement_values(source.Placement), "link_placement": placement_values(source.LinkPlacement) if source.isDerivedFrom("App::Link") else None, "definition_placement": placement_values(definition.Placement), "parameters": parameters, } drawing_snapshots[stage] = { "document": page.Document.Name, "page": page.Name, "page_scale": float(page.Scale), "projection_signature": signature, "views": views, "sources": sources, } path = out / "command-assembly-drawing-snapshots.json" path.write_text(json.dumps(drawing_snapshots, ensure_ascii=False, indent=2), encoding="utf-8") if str(path) not in report["artifacts"]: report["artifacts"].append(str(path)) def assembly_drawing_command_roundtrip(): Gui.Selection.clearSelection() Gui.runCommand("QI_NewAssembly", 0) doc = App.ActiveDocument assert doc and doc.Name not in initial_docs, "New Assembly command did not create a project" doc.Label = "Проверка команды чертежа сборки" gui_doc = Gui.getDocument(doc.Name) if gui_doc.getInEdit(): gui_doc.resetEdit() model.create_assembly_demo(doc) assembly = doc.getObject("Assembly") links = [obj for obj in assembly.Group if obj.isDerivedFrom("App::Link")] assert len(links) == 3 and assembly.solve(False) == 0, "Native assembly fixture is not solved" gui_doc.activeView().setActiveObject("pdbody", None) Gui.Selection.clearSelection() assert not Gui.Selection.getSelection(), "Drawing fixture has an implicit selection" assert gui_doc.activeView().getActiveObject("pdbody") is None assert not any(obj.isDerivedFrom("TechDraw::DrawPage") for obj in doc.Objects) Gui.runCommand("QI_Drawing", 0) settle() pages = [obj for obj in doc.Objects if obj.isDerivedFrom("TechDraw::DrawPage")] assert len(pages) == 1, "Drawing command did not create a page for an unselected assembly" page = pages[0] assert_sources(page, links) assert page.Template.EditableTexts.get("Material") == "См. спецификацию", \ "Mixed assembly title block incorrectly declares its first component's material" before = projection_signature(page) snapshot_drawing("before_definition_edit", page, before) capture("command-assembly-drawing-before") plate = links[0].getLinkedObject() pin = links[1].getLinkedObject() model.set_dimensions(plate, {"QILength": 160}) model.set_dimensions(pin, {"QIHeight": 42}) doc.recompute() assert assembly.solve(False) == 0, "Assembly failed to solve after changing definitions" after = projection_signature(page) snapshot_drawing("after_definition_edit", page, after) front_height = max(edge[4] for edge in after["FrontView"]) - min( edge[3] for edge in after["FrontView"]) assert abs(front_height - 42 * page.Scale) < 1e-5, \ "Front projection retained an intermediate pin height after consecutive definition edits" assert all(before[name] != after[name] for name in before), \ "A native projection did not update after editing assembly definitions" assert abs(links[1].Shape.Volume - math.pi * 5.8 ** 2 * 42) < 1e-5 capture("command-assembly-drawing-updated") project = out / "command-assembly-drawing.FCStd" model.save_project(doc, project) assert project.is_file() and project.stat().st_size > 1000 report["artifacts"].append(str(project)) page_name, plate_name = page.Name, plate.Name link_names = [link.Name for link in links] gui_doc.resetEdit() Gui.Selection.clearSelection() App.closeDocument(doc.Name) reopened = App.openDocument(str(project)) App.setActiveDocument(reopened.Name) restored = reopened.getObject(page_name) restored_links = [reopened.getObject(name) for name in link_names] assert_sources(restored, restored_links) restored_signature = projection_signature(restored) snapshot_drawing("reopened_immediate", restored, restored_signature) # The bundled native TechDraw regression test allows two seconds for # HLR threads. Record that settled state separately before changing any # equality requirement, so actual geometry and async deltas are visible. settle(2200) settled_signature = projection_signature(restored) snapshot_drawing("reopened_after_native_thread_wait", restored, settled_signature) assert restored_signature == after, "FCStd reopening changed native projections" assert reopened.getObject("Assembly").solve(False) == 0 model.set_dimensions(reopened.getObject(plate_name), {"QILength": 180}) reopened.recompute() after_reopen_edit = projection_signature(restored) snapshot_drawing("after_reopened_definition_edit", restored, after_reopen_edit) assert all(after[name] != after_reopen_edit[name] for name in after), \ "Reopened drawing lost its association to component definitions" model.save_project(reopened, project) svg = Path(model.export_drawing(restored, out / "command-assembly-drawing.svg")) pdf = Path(model.export_drawing(restored, out / "command-assembly-drawing.pdf")) assert svg.stat().st_size > 1000 and model._svg_has_geometry(svg) assert pdf.stat().st_size > 1000 and pdf.read_bytes().startswith(b"%PDF-") report["artifacts"].extend([str(svg), str(pdf)]) capture("command-assembly-drawing-reopened") return {"selection_required": False, "linked_components": len(links), "views": 3, "definition_edits_update_native_projections": True, "fcstd_reopen_preserves_associativity": True, "pdf_bytes": pdf.stat().st_size, "svg_bytes": svg.stat().st_size} def empty_drawing_rejection(): doc = model.new_part("Пустой документ для чертежа") Gui.Selection.clearSelection() before = document_state(doc) try: commands._drawing() except ValueError: pass else: raise AssertionError("Empty document was accepted by the Drawing command") assert document_state(doc) == before, "Rejected drawing mutated objects, save state or Undo history" assert not any(obj.isDerivedFrom("TechDraw::DrawPage") for obj in doc.Objects) return {"empty_rejected": True, "objects_and_history_preserved": True} def assembly_title_material_uses_actual_sources(): doc = model.new_assembly("Материал выбранной сборки") assembly = doc.getObject("Assembly") steel = model.create_box(20, 10, 5, doc) model.set_material(steel, "Сталь") link = model.add_component(assembly, steel) steel.ViewObject.Visibility = False unrelated = model.create_box(12, 8, 4, doc) model.set_material(unrelated, "Алюминий") doc.recompute() assert {row["material"] for row in model.bom_rows(doc)} == {"Сталь", "Алюминий"} Gui.Selection.clearSelection() page = model.create_drawing(doc) assert_sources(page, [link]) assert page.Template.EditableTexts.get("Material") == "Сталь", \ "An unrelated document part contaminated the assembly drawing material" assert page.Template.EditableTexts.get("Designation") == steel.QIPartNumber, \ "The title designation did not follow the actual drawing source" capture("command-assembly-title-material-scope") return {"drawing_source": link.Name, "material": "Сталь", "unrelated_aluminum_part_ignored_by_title": True} def failed_cached_solid_drawing_rejection(): doc = model.new_part("Ошибка перестроения перед чертежом") body = model.create_box(20, 10, 5, doc) pad = body.Tip cached_volume = body.Shape.Volume pad.setExpression("Length", None) pad.Length = 0 doc.recompute() assert "Invalid" in pad.State and pad.getStatusString() != "Valid", \ "Fixture did not trigger a real native Pad error" assert body.Shape.isValid() and abs(body.Shape.Volume - cached_volume) < 1e-6, \ "Fixture did not retain the old valid cached solid" Gui.Selection.clearSelection() before = document_state(doc) try: commands._drawing() except ValueError as exc: assert "ошибку перестроения" in str(exc), str(exc) else: raise AssertionError("Drawing command accepted cached geometry after a failed native rebuild") assert document_state(doc) == before, "Rejected cached-solid drawing added objects or an Undo step" assert not any(obj.isDerivedFrom("TechDraw::DrawPage") for obj in doc.Objects) return {"native_error": pad.getStatusString(), "cached_volume_mm3": cached_volume, "page_not_created": True, "objects_and_history_preserved": True} def existing_page_failed_source_export_rejection(): import Part from uuid import uuid4 source_doc = model.new_part("Источник существующего чертежа") source = model.create_box(20, 10, 5, source_doc) model.save_project(source_doc, out / "existing-drawing-guard-source.FCStd") doc = model.new_assembly("Существующий чертёж связанной детали") model.save_project(doc, out / "existing-drawing-guard-sheet.FCStd") link = model.add_component(doc.getObject("Assembly"), source) page = model.create_drawing(doc) model.prepare_drawing(page) assert all(view.Source == [doc.getObject("Assembly")] for view in page.Views) model.create_box(12, 8, 6, doc) wire = doc.addObject("Part::Feature", "IndependentWire") wire.Shape = Part.makePolygon([App.Vector(), App.Vector(10, 2, 6), App.Vector(18, 4, 8), App.Vector(24, 8, 10)]) independent_page = model.create_drawing(doc) for view in independent_page.Views: view.Source = [wire] doc.recompute() model.prepare_drawing(independent_page) assert wire.Shape.isValid() and not wire.Shape.Solids pad = source.Tip cached_volume = source.Shape.Volume pad.Profile = None source_doc.recompute() assert "Invalid" in pad.State and pad.Shape.isValid() and not pad.Shape.isNull() assert source.Shape.isValid() and abs(source.Shape.Volume - cached_volume) < 1e-6 before = (document_state(source_doc), document_state(doc), App.ActiveDocument.Name) rejected = {} for suffix in ("dxf", "pdf", "svg"): previous = out / ("existing-page-protected." + suffix) previous_bytes = ("preserve-previous-" + suffix).encode("ascii") previous.write_bytes(previous_bytes) missing = out / ("rejected-page-parent-" + uuid4().hex) / ("drawing." + suffix) assert not missing.parent.exists() for target in (previous, missing): try: model.export_drawing(page, target) except ValueError as exc: assert "ошибку перестроения" in str(exc), str(exc) rejected[suffix] = str(exc) else: raise AssertionError("Existing page exported cached failed source: " + suffix) assert previous.read_bytes() == previous_bytes, "Rejected export overwrote previous file" assert not missing.exists() and not missing.parent.exists(), "Rejected export created a path" assert before == (document_state(source_doc), document_state(doc), App.ActiveDocument.Name), \ "Rejected export modified model objects, history, save state or active document" valid_outputs = {} for suffix in ("dxf", "pdf", "svg"): target = Path(model.export_drawing(independent_page, out / ("independent-wire-page." + suffix))) assert target.is_file() and target.stat().st_size > 1000 valid_outputs[suffix] = target.stat().st_size assert "Invalid" in pad.State, "Independent export silently repaired the unrelated source" return {"linked_cached_native_failure_rejected": rejected, "cached_volume_mm3": cached_volume, "existing_outputs_and_absent_directories_preserved": True, "native_wire_page_exports_despite_unrelated_failed_source": valid_outputs} try: check("actual_plane_dialog_cancel_without_document", cancel_plane_without_document) check("assembly_drawing_command_definition_edit_and_fcstd_exports", assembly_drawing_command_roundtrip) check("empty_document_drawing_rejected_without_mutation", empty_drawing_rejection) check("assembly_title_material_follows_actual_view_sources", assembly_title_material_uses_actual_sources) check("failed_cached_native_solid_drawing_rejected_without_mutation", failed_cached_solid_drawing_rejection) check("existing_page_cached_linked_source_exports_rejected", existing_page_failed_source_export_rejection) finally: Gui.Selection.clearSelection() for name in set(App.listDocuments()) - initial_docs: gui_doc = Gui.getDocument(name) if gui_doc.getInEdit(): gui_doc.resetEdit() App.closeDocument(name) if initial_active in App.listDocuments(): App.setActiveDocument(initial_active) else: App.setActiveDocument("") ui.select_tab("3D-модель") report["status"] = "passed" if report["failed"] == 0 else "failed" (out / "command-workflows-report.json").write_text( json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") return report