Files
Qinventor/tests/parameter_table_gui_suite.py

734 lines
38 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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