"""Actual Qt input for document parameters, native formulas and solid rebuilds.""" import json import hashlib import io import math from pathlib import Path import re import sys import time import traceback import zipfile import FreeCAD as App import FreeCADGui as Gui import Part import Sketcher from PySide import QtCore, QtGui try: from PySide import QtWidgets except ImportError: QtWidgets = QtGui try: from PySide6 import QtTest except ImportError: from PySide2 import QtTest SOURCE = Path(__file__).resolve().parents[1] / "src" / "QInventor" if str(SOURCE) not in sys.path: sys.path.insert(0, str(SOURCE)) import qi_parameters_ui as ui def _near(actual, expected): assert math.isclose(float(actual), float(expected), rel_tol=1e-9, abs_tol=1e-7), (actual, expected) def _solid(prefix, radius=3.0): doc = App.newDocument("QIParameterTable") doc.Label = prefix doc.UndoMode = 1 body = doc.addObject("PartDesign::Body", "Body") body.Label = "Деталь" sketch = body.newObject("Sketcher::SketchObject", "Sketch") sketch.Label = "Круглый профиль" sketch.addGeometry(Part.Circle(App.Vector(), App.Vector(0, 0, 1), radius), False) sketch.addConstraint(Sketcher.Constraint("Coincident", 0, 3, -1, 1)) index = sketch.addConstraint(Sketcher.Constraint("Radius", 0, radius)) sketch.renameConstraint(index, "Radius") pad = body.newObject("PartDesign::Pad", "Pad") pad.Label = "Выдавливание" pad.Profile = sketch pad.Length = 10 body.Tip = pad doc.recompute() assert sketch.solve() == 0 and body.Shape.isValid() and len(body.Shape.Solids) == 1 sketch.Visibility = False return doc, body, sketch, pad, index def _state(doc): # dumpContent is a ZIP archive whose envelope records the current DOS time. # Compare EVERY native persistence/shape/map member byte-for-byte, while # excluding only fresh archive creation timestamps from model state. contents = [] for obj in doc.Objects: with zipfile.ZipFile(io.BytesIO(bytes(obj.dumpContent()))) as archive: members = tuple((member.filename, archive.read(member)) for member in archive.infolist()) contents.append((obj.Name, members, tuple(obj.State))) return (tuple(contents), doc.UndoCount, doc.RedoCount, Gui.getDocument(doc.Name).Modified) def _row(dialog, name, obj=None): table = dialog.table for row in range(table.rowCount()): editor = table.cellWidget(row, ui.ParameterTable.NAME) object_item = table.item(row, ui.ParameterTable.OBJECT) if editor and editor.text() == name and (obj is None or object_item.toolTip() == obj): return row raise AssertionError("Parameter row missing: %s.%s" % (obj, name)) def _keyboard(editor, text): editor.setFocus() editor.selectAll() QtTest.QTest.keyClick(editor, QtCore.Qt.Key_Backspace) if text.isascii(): QtTest.QTest.keyClicks(editor, text) else: # QTest.keyClicks only accepts Latin-1. Deliver Russian text through # Qt's native input-method event, as an actual IME would do. event = QtGui.QInputMethodEvent() event.setCommitString(text) QtWidgets.QApplication.sendEvent(editor, event) QtWidgets.QApplication.processEvents() assert editor.text() == text, (editor.text(), text) def _input(dialog, name, text, obj=None): row = _row(dialog, name, obj) editor = dialog.table.cellWidget(row, ui.ParameterTable.INPUT) assert editor and not editor.isReadOnly() _keyboard(editor, text) def _click(button): QtTest.QTest.mouseClick(button, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(50) def _apply(dialog): _click(dialog.buttons.button(QtWidgets.QDialogButtonBox.Apply)) def _add(dialog, name, text, kind="length", comment=""): _click(dialog.add_button) row = dialog.table.currentRow() _keyboard(dialog.table.cellWidget(row, ui.ParameterTable.NAME), name) units = dialog.table.cellWidget(row, ui.ParameterTable.UNIT) units.setCurrentIndex(units.findData(kind)) _keyboard(dialog.table.cellWidget(row, ui.ParameterTable.INPUT), text) if comment: _keyboard(dialog.table.cellWidget(row, ui.ParameterTable.COMMENT), comment) return row def run(output_dir): output = Path(output_dir).absolute() output.mkdir(parents=True, exist_ok=True) report_path = output / "parameter-table-gui-report.json" report = {"status": "running", "passed": 0, "failed": 0, "checks": [], "input_delivery": "actual Qt buttons, keyboard and Russian input-method events in QI_ParameterTable", "kernel_boundary": "native Sketcher, PartDesign Pad, App::VarSet and FreeCAD expressions", "os_pointer_access": False} original_docs = set(App.listDocuments()) active_name = App.ActiveDocument.Name if App.ActiveDocument else None dialogs = [] diagnostics = {} main = Gui.getMainWindow() main.resize(1440, 940) # A hidden launcher can leave the native surface unexposed even when the # QWidget reports visibility. Use the same exposure path as the main suite. main.hide() main.showNormal() main.raise_() main.activateWindow() QtWidgets.QApplication.processEvents() assert QtTest.QTest.qWaitForWindowExposed(main.windowHandle(), 5000) def write(): report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str), encoding="utf-8") def check(name, action): report["running_case"] = name write() try: evidence = action() 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 write() def open_table(doc): Gui.Selection.clearSelection() dialog = ui.show_parameters(doc) dialogs.append(dialog) QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) assert dialog.objectName() == "QI_ParameterTable" and dialog.isVisible() and not dialog.isModal() return dialog def capture(dialog, name): QtWidgets.QApplication.processEvents() path = output / (name + ".png") assert dialog.grab().save(str(path)) return str(path) def diagnostic_stage(case, stage, doc): """Persist exact native serialization beside readable property evidence.""" state = _state(doc) directory = output / "parameter-table-state" directory.mkdir(parents=True, exist_ok=True) members = {} for obj in doc.Objects: object_name = obj.Name content = obj.dumpContent() raw = bytes(content) path = directory / (case + "-" + stage + "-" + object_name + ".dump") path.write_bytes(raw) with zipfile.ZipFile(io.BytesIO(raw)) as archive: archive_members = [{"name": member.filename, "date_time": member.date_time, "payload_bytes": len(archive.read(member)), "payload_sha256": hashlib.sha256(archive.read(member)).hexdigest()} for member in archive.infolist()] properties = {} for property_name in obj.PropertiesList: try: value = obj.getPropertyByName(property_name) if hasattr(value, "Document") and hasattr(value, "Name"): value = value.Document.Name + "/" + value.Name elif property_name == "Shape": value = {"null": value.isNull(), "valid": value.isValid(), "solids": len(value.Solids), "volume": value.Volume, "area": value.Area, "bounds": str(value.BoundBox)} elif hasattr(value, "Value") and hasattr(value, "Unit"): value = {"value": value.Value, "unit": str(value.Unit)} else: value = str(value) properties[property_name] = value except Exception as exc: properties[property_name] = {"read_error": str(exc)} members[object_name] = {"type": obj.TypeId, "state": list(obj.State), "dump_type": type(content).__name__, "dump_bytes": len(raw), "dump_sha256": hashlib.sha256(raw).hexdigest(), "dump_path": str(path), "zip_members": archive_members, "properties": properties} diagnostics.setdefault(case, {})[stage] = {"objects": members, "undo": state[1], "redo": state[2], "gui_modified": state[3], "active_transaction": str(App.getActiveTransaction())} (output / "parameter-table-state-diagnostics.json").write_text( json.dumps(diagnostics, ensure_ascii=False, indent=2, default=str), encoding="utf-8") return state def wait_hlr(): """Drain native TechDraw worker callbacks before closing their owners.""" pool = QtCore.QThreadPool.globalInstance() deadline = time.monotonic() + 10 idle_turns = 0 while time.monotonic() < deadline: Gui.updateGui() QtWidgets.QApplication.processEvents() idle_turns = idle_turns + 1 if pool.activeThreadCount() == 0 else 0 if idle_turns >= 3: return QtTest.QTest.qWait(20) raise AssertionError("Native TechDraw workers did not finish before document cleanup") def user_formulas_and_shape(): doc, body, sketch, pad, index = _solid("Параметры и цепочка формул") dialog = open_table(doc) before_undo = doc.UndoCount _add(dialog, "Size", "12 mm", comment="Основной диаметр") _add(dialog, "Depth", "QIUserParameters.Size * 2") _input(dialog, "Radius", "QIUserParameters.Size / 2", sketch.Name) _input(dialog, "Length", "QIUserParameters.Depth", pad.Name) # Nothing reaches the document while users are typing. assert doc.getObject("QIUserParameters") is None _near(sketch.Geometry[0].Radius, 3) _near(pad.Length.Value, 10) _apply(dialog) assert "применены" in dialog.status.text(), dialog.status.text() params = doc.getObject("QIUserParameters") assert params is not None and params.TypeId == "App::VarSet" assert params.getDocumentationOfProperty("Size") == "Основной диаметр" _near(params.Size.Value, 12) _near(params.Depth.Value, 24) _near(sketch.Geometry[0].Radius, 6) _near(body.Shape.Volume, math.pi * 6 ** 2 * 24) assert sketch.solve() == 0 and body.Shape.isValid() and len(body.Shape.Solids) == 1 assert doc.UndoCount == before_undo + 1 assert dict(pad.ExpressionEngine)["Length"] == "QIUserParameters.Depth" dialog.table.selectRow(_row(dialog, "Size", params.Name)) assert dialog.reference.text() == "QIUserParameters.Size" _input(dialog, "Size", "16 mm", params.Name) # Filtering must preserve and apply drafts in hidden rows too. _keyboard(dialog.search, "Pad") assert dialog.table.isRowHidden(_row(dialog, "Size", params.Name)) _apply(dialog) _near(sketch.Geometry[0].Radius, 8) _near(pad.Length.Value, 32) _near(body.Shape.Volume, math.pi * 8 ** 2 * 32) dialog.search.clear() undo = doc.UndoCount _apply(dialog) assert doc.UndoCount == undo and "не изменены" in dialog.status.text() screenshot = capture(dialog, "parameter-table-formulas") path = output / "document-parameters.FCStd" doc.saveAs(str(path)) dialog.close() QtWidgets.QApplication.processEvents() name = doc.Name App.closeDocument(name) restored = App.openDocument(str(path)) restored_params = restored.getObject("QIUserParameters") restored_sketch = restored.getObject("Sketch") restored_pad = restored.getObject("Pad") assert restored_params.TypeId == "App::VarSet" _near(restored_params.Size.Value, 16) _near(restored_params.Depth.Value, 32) _near(restored_sketch.Geometry[0].Radius, 8) assert dict(restored_pad.ExpressionEngine)["Length"] == "QIUserParameters.Depth" _near(restored.getObject("Body").Shape.Volume, math.pi * 8 ** 2 * 32) return {"screenshot": screenshot, "native_project": str(path), "new_user_and_model_formulas_one_undo": True, "expression_chain_drives_solid": True, "hidden_draft_applies": True, "FCStd_reopen": True} def invalid_units_and_cycles(): doc, body, sketch, pad, _ = _solid("Отклонение единиц и циклов") dialog = open_table(doc) _add(dialog, "Size", "12 mm") _add(dialog, "Depth", "QIUserParameters.Size * 2") _input(dialog, "Radius", "QIUserParameters.Size / 2", sketch.Name) _input(dialog, "Length", "QIUserParameters.Depth", pad.Name) _apply(dialog) assert "применены" in dialog.status.text(), dialog.status.text() before = diagnostic_stage("invalid", "before-edits", doc) _input(dialog, "Size", "30 deg", "QIUserParameters") diagnostic_stage("invalid", "after-size-input", doc) _input(dialog, "Length", "40 mm", pad.Name) diagnostic_stage("invalid", "after-pad-input", doc) _apply(dialog) assert dialog.isVisible() and "применены" not in dialog.status.text() after = diagnostic_stage("invalid", "after-rejected-apply", doc) assert after == before, "Invalid dimensional units changed native document or undo history" assert dialog.table.cellWidget(_row(dialog, "Size", "QIUserParameters"), ui.ParameterTable.INPUT).text() == "30 deg" unit_error = dialog.status.text() _input(dialog, "Size", "QIUserParameters.Depth", "QIUserParameters") _input(dialog, "Depth", "QIUserParameters.Size", "QIUserParameters") _apply(dialog) assert dialog.isVisible() and "применены" not in dialog.status.text() after = diagnostic_stage("invalid", "after-rejected-cycle", doc) assert after == before, "A cyclic formula changed native document or undo history" cycle_error = dialog.status.text() assert any("А" <= letter <= "я" or letter in "Ёё" for letter in unit_error) assert any("А" <= letter <= "я" or letter in "Ёё" for letter in cycle_error) assert "циклическ" in cycle_error and "формул" in cycle_error, cycle_error assert not re.search(r"\b(?:Property|Cyclic|circular|expression)\b|not\s+found", cycle_error, re.IGNORECASE), cycle_error screenshot = capture(dialog, "parameter-table-cycle-error") # Correcting the pending fields succeeds in the same still-open dialog. _input(dialog, "Size", "14 mm", "QIUserParameters") _input(dialog, "Depth", "QIUserParameters.Size * 2", "QIUserParameters") _input(dialog, "Length", "QIUserParameters.Depth", pad.Name) _apply(dialog) assert "применены" in dialog.status.text(), dialog.status.text() _near(body.Shape.Volume, math.pi * 7 ** 2 * 28) dialog.close() return {"units_error": unit_error, "cycle_error": cycle_error, "screenshot": screenshot, "errors_keep_document_and_drafts": True, "corrected_apply_rebuilds_native_solid": True} def precision_reference_and_close(): radius = 3.141592653589793 doc, body, sketch, pad, index = _solid("Точность, справочный размер и закрытие", radius) reference = sketch.addConstraint(Sketcher.Constraint("Diameter", 0, radius * 2)) sketch.setDriving(reference, False) sketch.renameConstraint(reference, "MeasuredDiameter") doc.recompute() before_open = _state(doc) native_comment = pad.getDocumentationOfProperty("Length") dialog = open_table(doc) assert _state(doc) == before_open, "Opening the table changed native model metadata or history" length_comment = dialog.table.cellWidget(_row(dialog, "Length", pad.Name), ui.ParameterTable.COMMENT) assert length_comment.text() == ui._NATIVE_DOCUMENTATION.get(native_comment, native_comment) assert pad.getDocumentationOfProperty("Length") == native_comment reference_row = _row(dialog, "MeasuredDiameter", sketch.Name) reference_input = dialog.table.cellWidget(reference_row, ui.ParameterTable.INPUT) assert reference_input.isReadOnly() assert "Справочный" in dialog.table.item(reference_row, ui.ParameterTable.MODE).text() before = diagnostic_stage("close", "before-noop", doc) exact_radius = sketch.getDatum(index).Value _apply(dialog) after = diagnostic_stage("close", "after-noop", doc) assert after == before and sketch.getDatum(index).Value == exact_radius assert pad.getDocumentationOfProperty("Length") == native_comment, "Display translation overwrote native documentation" _input(dialog, "Radius", "5 mm", sketch.Name) diagnostic_stage("close", "after-radius-input", doc) _add(dialog, "NotSaved", "10 mm") diagnostic_stage("close", "after-added-draft", doc) screenshot = capture(dialog, "parameter-table-reference-and-draft") diagnostic_stage("close", "after-screenshot", doc) _click(dialog.buttons.button(QtWidgets.QDialogButtonBox.Close)) after = diagnostic_stage("close", "after-close", doc) assert after == before and doc.getObject("QIUserParameters") is None reopened = open_table(doc) assert all(reopened.table.cellWidget(row, ui.ParameterTable.NAME).text() != "NotSaved" for row in range(reopened.table.rowCount())) _near(sketch.getDatum(index).Value, radius) assert not sketch.getDriving(reference) reopened.close() return {"screenshot": screenshot, "untouched_native_precision": exact_radius, "reference_not_editable": True, "close_discards_unapplied_rows_and_values": True} def rename_and_delete(): doc, body, sketch, pad, _ = _solid("Переименование и удаление параметров") dialog = open_table(doc) _add(dialog, "Size", "12 mm") _add(dialog, "Unused", "2", "scalar", comment="Pad length") _input(dialog, "Radius", "QIUserParameters.Size / 2", sketch.Name) _apply(dialog) assert "применены" in dialog.status.text(), dialog.status.text() custom_comment = dialog.table.cellWidget(_row(dialog, "Unused", "QIUserParameters"), ui.ParameterTable.COMMENT) assert custom_comment.text() == "Pad length", "A user comment was translated as native documentation" size_row = _row(dialog, "Size", "QIUserParameters") _keyboard(dialog.table.cellWidget(size_row, ui.ParameterTable.NAME), "Diameter") _apply(dialog) assert "применены" in dialog.status.text(), dialog.status.text() params = doc.getObject("QIUserParameters") assert "Diameter" in params.PropertiesList and "Size" not in params.PropertiesList expressions = dict(sketch.ExpressionEngine) assert any("QIUserParameters.Diameter" in expr for expr in expressions.values()) _near(body.Shape.Volume, math.pi * 6 ** 2 * 10) before = _state(doc) dialog.table.selectRow(_row(dialog, "Diameter", params.Name)) assert dialog.delete_button.isEnabled() _click(dialog.delete_button) _apply(dialog) assert _state(doc) == before and dialog.isVisible() assert "применены" not in dialog.status.text() deletion_error = dialog.status.text() assert "не найден" in deletion_error and "ссылк" in deletion_error, deletion_error assert not re.search(r"\b(?:Property|Cyclic|circular|expression)\b|not\s+found", deletion_error, re.IGNORECASE), deletion_error dialog.close() QtWidgets.QApplication.processEvents() reopened = open_table(doc) reopened.table.selectRow(_row(reopened, "Unused", params.Name)) _click(reopened.delete_button) undo = doc.UndoCount _apply(reopened) assert "применены" in reopened.status.text(), reopened.status.text() assert "Unused" not in params.PropertiesList and doc.UndoCount == undo + 1 _near(body.Shape.Volume, math.pi * 6 ** 2 * 10) reopened.close() return {"native_rename_updates_consumers": True, "referenced_delete_rejected": deletion_error, "unused_parameter_removed_in_one_undo": True} def closed_document_rejected(): doc, _, _, _, _ = _solid("Защита закрытого документа") dialog = open_table(doc) _add(dialog, "Draft", "20 mm") name = doc.Name App.closeDocument(name) _apply(dialog) assert dialog.isVisible() and "закрыт" in dialog.status.text(), dialog.status.text() assert name not in App.listDocuments() dialog.close() return {"closed_document_has_no_write": True, "dialog_error_stays_visible": True} def native_task_rejected(): doc, _, sketch, pad, _ = _solid("Защита редактируемой операции") dialog = open_table(doc) _input(dialog, "Length", "20 mm", pad.Name) gui_doc = Gui.getDocument(doc.Name) assert gui_doc.setEdit(sketch.Name) QtWidgets.QApplication.processEvents() assert Gui.Control.activeDialog(), "Test requires an actual native Sketcher task" before = _state(doc) try: _apply(dialog) assert dialog.isVisible() and "Завершите" in dialog.status.text(), dialog.status.text() assert _state(doc) == before _near(pad.Length.Value, 10) finally: gui_doc.resetEdit() QtWidgets.QApplication.processEvents() _apply(dialog) assert "применены" in dialog.status.text(), dialog.status.text() _near(pad.Length.Value, 20) dialog.close() return {"actual_Sketcher_task_blocks_floating_apply": True, "after_finishing_task_apply_succeeds": True} def grouped_drawing_and_command(): import qi_commands as commands import qi_model as model doc, body, sketch, pad, _ = _solid("Параметры детали и существующего чертежа") page = model.create_drawing(doc) group = doc.addObject("App::DocumentObjectGroup", "DesignGroup") group.Label = "Деталь и чертёж" group.addObject(body) group.addObject(page) doc.recompute() expected_group = {body.Name, page.Name} gui_doc = Gui.getDocument(doc.Name) def projection_height(target): model.prepare_drawing(target) front = target.Document.getObject("FrontView") edges = front.getVisibleEdges() assert front.getStatusString() == "Valid" and len(edges) >= 4 assert all(view.Source == [target.Document.getObject("Body")] for view in target.Views) height = max(edge.BoundBox.YMax for edge in edges) - min(edge.BoundBox.YMin for edge in edges) current_gui = Gui.getDocument(target.Document.Name) if current_gui.getInEdit(): current_gui.resetEdit() wait_hlr() return height _near(projection_height(page), 10 * page.Scale) assert {member.Name for member in group.Group} == expected_group commands.register_commands() Gui.Selection.clearSelection() assert not Gui.Selection.getSelection() assert commands.COMMANDS["QI_Dimensions"].IsActive() documents_before = set(App.listDocuments()) Gui.runCommand("QI_Dimensions", 0) QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) candidates = [dialog for dialog in main.findChildren(QtWidgets.QDialog, "QI_ParameterTable") if dialog.isVisible() and getattr(dialog, "document", None) == doc] assert len(candidates) == 1, "QI_Dimensions did not open the document table without selection" dialog = candidates[0] dialogs.append(dialog) _add(dialog, "Size", "12 mm") _add(dialog, "Depth", "QIUserParameters.Size * 2") _input(dialog, "Radius", "QIUserParameters.Size / 2", sketch.Name) _input(dialog, "Length", "QIUserParameters.Depth", pad.Name) _apply(dialog) assert "применены" in dialog.status.text(), dialog.status.text() assert App.ActiveDocument is doc and set(App.listDocuments()) == documents_before assert {member.Name for member in group.Group} == expected_group _near(body.Shape.Volume, math.pi * 6 ** 2 * 24) first_height = projection_height(page) _near(first_height, 24 * page.Scale) _input(dialog, "Size", "16 mm", "QIUserParameters") _apply(dialog) assert "применены" in dialog.status.text(), dialog.status.text() assert App.ActiveDocument is doc and set(App.listDocuments()) == documents_before assert {member.Name for member in group.Group} == expected_group _near(body.Shape.Volume, math.pi * 8 ** 2 * 32) updated_height = projection_height(page) _near(updated_height, 32 * page.Scale) # Exercise the grouped-page preflight on a real existing Redo branch. _input(dialog, "Size", "18 mm", "QIUserParameters") _apply(dialog) assert "применены" in dialog.status.text(), dialog.status.text() _near(body.Shape.Volume, math.pi * 9 ** 2 * 36) _near(projection_height(page), 36 * page.Scale) doc.undo() doc.recompute() _near(body.Shape.Volume, math.pi * 8 ** 2 * 32) _near(projection_height(page), 32 * page.Scale) assert doc.RedoCount > 0, "Grouped drawing fixture needs a real native Redo branch" # Reload after external Undo so Size=0 reaches the real native grouped # geometry preflight rather than the table's stale-edit protection. before_refresh = _state(doc) _click(dialog.refresh_button) assert "обновлена" in dialog.status.text(), dialog.status.text() assert _state(doc) == before_refresh, "Refreshing after Undo changed native state or its Redo branch" before_rejection = _state(doc) _input(dialog, "Size", "0 mm", "QIUserParameters") _apply(dialog) assert "применены" not in dialog.status.text() and dialog.isVisible() assert _state(doc) == before_rejection, "Rejected grouped drawing geometry changed model data or history" assert App.ActiveDocument is doc and set(App.listDocuments()) == documents_before assert {member.Name for member in group.Group} == expected_group rejected_geometry = dialog.status.text() screenshot = capture(dialog, "parameter-table-existing-grouped-drawing") dialog.close() QtWidgets.QApplication.processEvents() wait_hlr() doc.redo() doc.recompute() _near(body.Shape.Volume, math.pi * 9 ** 2 * 36) _near(projection_height(page), 36 * page.Scale) doc.undo() doc.recompute() _near(body.Shape.Volume, math.pi * 8 ** 2 * 32) _near(projection_height(page), 32 * page.Scale) pdf = Path(model.export_drawing(page, output / "parameter-table-grouped-drawing.pdf")) svg = Path(model.export_drawing(page, output / "parameter-table-grouped-drawing.svg")) assert pdf.read_bytes().startswith(b"%PDF-") and pdf.stat().st_size > 1000 assert svg.stat().st_size > 1000 and model._svg_has_geometry(svg) if gui_doc.getInEdit(): gui_doc.resetEdit() wait_hlr() project = Path(model.save_project(doc, output / "parameter-table-grouped-drawing.FCStd")) assert project.is_file() and project.stat().st_size > 1000 assert App.ActiveDocument is doc and set(App.listDocuments()) == documents_before assert {member.Name for member in group.Group} == expected_group name = doc.Name wait_hlr() App.closeDocument(name) restored = App.openDocument(str(project)) restored_group = restored.getObject("DesignGroup") restored_page = restored.getObject("Drawing") assert {member.Name for member in restored_group.Group} == expected_group _near(restored.getObject("Body").Shape.Volume, math.pi * 8 ** 2 * 32) restored_height = projection_height(restored_page) _near(restored_height, 32 * restored_page.Scale) assert dict(restored.getObject("Pad").ExpressionEngine)["Length"] == "QIUserParameters.Depth" assert not any(name.startswith("QIParameterValidation") for name in App.listDocuments()) wait_hlr() return {"screenshot": screenshot, "opened_by_QI_Dimensions_without_selection": True, "grouped_native_page_preserved": True, "front_heights_mm": [first_height, updated_height, restored_height], "new_expressions_drive_existing_projection": True, "FCStd_group_and_association_reopen": True, "rejected_geometry_preserves_usable_native_redo": rejected_geometry, "no_hidden_validation_documents": True, "pdf": str(pdf), "svg": str(svg), "project": str(project)} def filter_protects_hidden_selection(): doc, _, _, _, _ = _solid("Фильтр и безопасное удаление параметров") dialog = open_table(doc) _add(dialog, "Unused", "10 mm") _apply(dialog) assert "применены" in dialog.status.text(), dialog.status.text() row = _row(dialog, "Unused", "QIUserParameters") dialog.table.selectRow(row) assert dialog.delete_button.isEnabled() and dialog.reference.text() == "QIUserParameters.Unused" before = _state(doc) count = dialog.table.rowCount() _keyboard(dialog.search, "Pad") assert dialog.table.isRowHidden(row) assert dialog.table.currentRow() == -1 and not dialog.table.selectedIndexes() assert not dialog.delete_button.isEnabled() and not dialog.reference.text() _click(dialog.delete_button) # A delayed/programmatic slot delivery must also refuse hidden rows. dialog.table.setCurrentCell(row, ui.ParameterTable.NAME) dialog.delete_parameter() assert dialog.table.rowCount() == count assert _row(dialog, "Unused", "QIUserParameters") == row _apply(dialog) assert _state(doc) == before and "Unused" in doc.getObject("QIUserParameters").PropertiesList assert "не изменены" in dialog.status.text(), dialog.status.text() assert not dialog.delete_button.isEnabled() and not dialog.reference.text() screenshot = capture(dialog, "parameter-table-filter-protected-selection") _keyboard(dialog.search, "") dialog.table.selectRow(_row(dialog, "Unused", "QIUserParameters")) assert dialog.delete_button.isEnabled() and dialog.reference.text() == "QIUserParameters.Unused" dialog.close() return {"screenshot": screenshot, "hidden_selected_row_cleared": True, "hidden_delete_slot_refuses_removal": True, "filter_does_not_write_native_document": True} def stale_native_edits_and_constraint_shift(): doc, body, sketch, pad, radius_index = _solid("Обновление параметров после штатного редактирования") dialog = open_table(doc) _input(dialog, "Length", "20 mm", pad.Name) _apply(dialog) assert "применены" in dialog.status.text(), dialog.status.text() doc.undo() doc.recompute() _near(pad.Length.Value, 10) assert doc.RedoCount > 0 before_unchanged_apply = _state(doc) _apply(dialog) assert "обновлена" in dialog.status.text().lower(), dialog.status.text() assert _state(doc) == before_unchanged_apply current_input = dialog.table.cellWidget(_row(dialog, "Length", pad.Name), ui.ParameterTable.INPUT) assert current_input.text() == "10 mm" _input(dialog, "Length", "15 mm", pad.Name) # This public native transaction represents an edit outside the table; # table typing, Apply and explicit Refresh use real Qt input below. doc.openTransaction("Внешнее изменение штатного свойства") pad.Length = 12 doc.recompute() doc.commitTransaction() before_rejected_apply = _state(doc) _apply(dialog) assert "Документ изменён вне таблицы" in dialog.status.text(), dialog.status.text() assert "Обновить" in dialog.status.text() assert _state(doc) == before_rejected_apply assert current_input.text() == "15 mm" and dialog.isVisible() _click(dialog.refresh_button) assert _state(doc) == before_rejected_apply current_input = dialog.table.cellWidget(_row(dialog, "Length", pad.Name), ui.ParameterTable.INPUT) assert current_input.text() == "12 mm" # Two unnamed dimensions give the stale index a real different target. doc.openTransaction("Два безымянных размера эскиза") sketch.renameConstraint(radius_index, "") assert not sketch.Constraints[radius_index].Name line = sketch.addGeometry(Part.LineSegment(App.Vector(20, 0, 0), App.Vector(30, 0, 0)), True) length_index = sketch.addConstraint(Sketcher.Constraint("Distance", line, 10)) assert length_index == radius_index + 1 and not sketch.Constraints[length_index].Name doc.recompute() doc.commitTransaction() assert sketch.solve() == 0 and body.Shape.isValid() _click(dialog.refresh_button) _input(dialog, "Размер %d" % (radius_index + 1), "5 mm", sketch.Name) stale_editor = dialog.table.cellWidget(_row(dialog, "Размер %d" % (radius_index + 1), sketch.Name), ui.ParameterTable.INPUT) doc.openTransaction("Сдвиг индексов ограничений вне таблицы") sketch.delConstraint(0) doc.recompute() doc.commitTransaction() assert sketch.solve() == 0 assert sketch.Constraints[radius_index].Type == "Distance" _near(sketch.getDatum(radius_index).Value, 10) before_index_rejection = _state(doc) _apply(dialog) assert "Документ изменён вне таблицы" in dialog.status.text(), dialog.status.text() assert _state(doc) == before_index_rejection assert stale_editor.text() == "5 mm" and dialog.isVisible() _near(sketch.Geometry[0].Radius, 3) _near(sketch.getDatum(radius_index).Value, 10) screenshot = capture(dialog, "parameter-table-stale-constraint-edit") _click(dialog.refresh_button) assert _state(doc) == before_index_rejection # The explicitly refreshed row now refers to the line and can be edited. _input(dialog, "Размер %d" % (radius_index + 1), "12 mm", sketch.Name) _apply(dialog) assert "применены" in dialog.status.text(), dialog.status.text() _near(sketch.getDatum(radius_index).Value, 12) _near(sketch.Geometry[0].Radius, 3) _near(body.Shape.Volume, math.pi * 3 ** 2 * 12) own_apply_state = _state(doc) _apply(dialog) assert _state(doc) == own_apply_state and "не изменены" in dialog.status.text() dialog.close() return {"screenshot": screenshot, "undo_refresh_preserves_redo": True, "external_native_property_rejects_stale_draft": True, "unnamed_constraint_index_shift_rejects_old_target": True, "refresh_is_read_only_and_discards_drafts": True, "own_apply_updates_baseline": True, "external_edit_delivery": "FreeCAD native transactions and public property/constraint APIs"} try: for name, action in (("Add user parameters, edit model formulas and rebuild native solid", user_formulas_and_shape), ("Units and cyclic formulas reject atomically and recover in the open table", invalid_units_and_cycles), ("Reference dimensions, unchanged precision and Close discard drafts", precision_reference_and_close), ("User rename updates formula consumers and guarded deletion", rename_and_delete), ("Closed document refuses pending Apply", closed_document_rejected), ("Native Sketcher task protects its edit transaction", native_task_rejected), ("Parameter command updates a grouped existing drawing, exports and reopens", grouped_drawing_and_command), ("Filtering clears hidden user selection and prevents unseen deletion", filter_protects_hidden_selection), ("Native Undo, external edits and unnamed index shifts protect table drafts", stale_native_edits_and_constraint_shift)): check(name, action) finally: for dialog in dialogs: try: dialog.close() except RuntimeError: pass QtWidgets.QApplication.processEvents() Gui.Selection.clearSelection() for name in set(App.listDocuments()) - original_docs: gui_doc = Gui.getDocument(name) if gui_doc.getInEdit(): gui_doc.resetEdit() wait_hlr() for name in set(App.listDocuments()) - original_docs: App.closeDocument(name) if active_name and active_name in App.listDocuments(): App.setActiveDocument(active_name) report.pop("running_case", None) report["status"] = "passed" if report["failed"] == 0 else "failed" report["count"] = len(report["checks"]) write() return report