734 lines
38 KiB
Python
734 lines
38 KiB
Python
"""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
|