Files
Qinventor/tests/parameters_suite.py
T

350 lines
17 KiB
Python

"""Typed parameters against native Sketcher, units, expressions and Qt controls."""
import json
import io
import math
from pathlib import Path
import sys
import traceback
import zipfile
import FreeCAD as App
import Part
import Sketcher
SOURCE = Path(__file__).resolve().parents[1] / "src" / "QInventor"
if str(SOURCE) not in sys.path:
sys.path.insert(0, str(SOURCE))
import qi_model as model
def _near(actual, expected):
assert math.isclose(float(actual), float(expected), rel_tol=1e-9, abs_tol=1e-7), (actual, expected)
def _rows(obj):
return {row["name"]: row for row in model.get_dimensions(obj)}
def _state(sketch):
# The native ZIP container timestamps vary during read-only calls. Every
# persisted member payload still participates in the exact state check.
raw = bytes(sketch.dumpContent())
if zipfile.is_zipfile(io.BytesIO(raw)):
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
content = tuple((name, archive.read(name)) for name in sorted(archive.namelist()))
else:
content = raw
return (content, list(sketch.ExpressionEngine),
sketch.Document.UndoCount, sketch.Document.RedoCount)
def _reject(action):
try:
action()
except ValueError:
return
raise AssertionError("Invalid or read-only parameter was accepted")
def _circle(prefix):
doc = model.new_part(prefix)
body = doc.addObject("PartDesign::Body", "Body")
sketch = body.newObject("Sketcher::SketchObject", "Sketch")
sketch.Label = "Координаты и размеры"
sketch.addGeometry(Part.Circle(App.Vector(-10, 20, 0), App.Vector(0, 0, 1), 3), False)
indices = {}
for constraint, name in ((Sketcher.Constraint("DistanceX", 0, 3, -10), "ЦентрX"),
(Sketcher.Constraint("DistanceY", 0, 3, 20), "ЦентрY"),
(Sketcher.Constraint("Radius", 0, 3), "РадиусКруга")):
index = sketch.addConstraint(constraint)
sketch.renameConstraint(index, name)
indices[name] = index
doc.recompute()
assert sketch.solve() == 0
return doc, body, sketch, indices
def _line(sketch):
index = sketch.addGeometry(Part.LineSegment(App.Vector(), App.Vector(10, 10, 0)), False)
sketch.addConstraint(Sketcher.Constraint("Coincident", index, 1, -1, 1))
length = sketch.addConstraint(Sketcher.Constraint("Distance", index, math.sqrt(200)))
sketch.renameConstraint(length, "ДлинаЛинии")
angle = sketch.addConstraint(Sketcher.Constraint("Angle", index, math.pi / 4))
sketch.renameConstraint(angle, "Наклон")
sketch.Document.recompute()
assert sketch.solve() == 0
return index, angle
def run(output_dir):
output = Path(output_dir).absolute()
output.mkdir(parents=True, exist_ok=True)
initial_docs = set(App.listDocuments())
active_name = App.ActiveDocument.Name if App.ActiveDocument else None
checks = []
def check(name, action):
(output / "parameters-report.json").write_text(
json.dumps({"checks": checks, "running_case": name}, ensure_ascii=False, indent=2), encoding="utf-8")
App.Console.PrintLog("QInventor parameters test: " + name + "\n")
try:
checks.append({"name": name, "passed": True, "evidence": action()})
except Exception:
checks.append({"name": name, "passed": False, "error": traceback.format_exc()})
(output / "parameters-report.json").write_text(
json.dumps({"checks": checks}, ensure_ascii=False, indent=2), encoding="utf-8")
def signed_coordinates_and_zero():
doc, _, sketch, ids = _circle("SignedCoordinates")
rows = _rows(sketch)
assert rows["ЦентрX"]["signed"] and rows["ЦентрY"]["signed"]
assert not rows["РадиусКруга"]["signed"]
App.Console.PrintLog("QInventor parameters test: apply signed X, zero Y and radius\n")
model.set_dimensions(sketch, {"ЦентрX": -18, "ЦентрY": 0, "РадиусКруга": 4})
App.Console.PrintLog("QInventor parameters test: signed/zero apply returned\n")
_near(sketch.Geometry[0].Center.x, -18)
_near(sketch.Geometry[0].Center.y, 0)
_near(sketch.Geometry[0].Radius, 4)
assert sketch.Constraints[ids["ЦентрY"]].Type == "DistanceY"
assert sketch.solve() == 0
doc.undo()
doc.recompute()
_near(sketch.Geometry[0].Center.x, -10)
_near(sketch.Geometry[0].Center.y, 20)
doc.redo()
doc.recompute()
_near(sketch.Geometry[0].Center.y, 0)
return {"signed_x_mm": -18, "zero_y_mm": 0, "native_undo_redo": True}
def angle_units():
_, _, sketch, _ = _circle("AngularParameters")
line, angle = _line(sketch)
row = _rows(sketch)["Наклон"]
assert row["unit"] == "°" and row["signed"]
_near(row["value"], 45)
model.set_dimension(sketch, "Наклон", App.Units.Quantity("-30 deg"))
_near(sketch.getDatum(angle).Value, -30)
_near(sketch.Constraints[angle].Value, -math.pi / 6)
direction = sketch.Geometry[line].EndPoint - sketch.Geometry[line].StartPoint
_near(math.degrees(math.atan2(direction.y, direction.x)), -30)
return {"editor_unit": "degrees", "kernel_constraint_unit": "radians", "signed_angle_degrees": -30}
def expression_paths_and_noop():
doc, _, sketch, ids = _circle("ExpressionParameters")
sizes = doc.addObject("App::VarSet", "SourceSizes")
sizes.addProperty("App::PropertyDistance", "X")
sizes.X = -12
sketch.setExpression("Constraints[%d]" % ids["ЦентрX"], "SourceSizes.X")
sketch.setExpression("Constraints.РадиусКруга", "4 mm")
doc.recompute()
expressions = list(sketch.ExpressionEngine)
rows = _rows(sketch)
assert rows["ЦентрX"]["read_only"] and rows["РадиусКруга"]["read_only"]
assert rows["ЦентрX"]["expression"] == "SourceSizes.X"
undo = (doc.UndoCount, doc.RedoCount)
assert model.set_dimensions(sketch, {name: row["value"] for name, row in rows.items()}) is False
assert (doc.UndoCount, doc.RedoCount) == undo
before = _state(sketch)
_reject(lambda: model.set_dimensions(sketch, {"ЦентрX": -99, "ЦентрY": -15}))
assert _state(sketch) == before
model.set_dimension(sketch, "ЦентрY", -15)
assert list(sketch.ExpressionEngine) == expressions
sizes.X = -25
doc.recompute()
_near(sketch.Geometry[0].Center.x, -25)
return {"indexed_and_named_formula_paths": expressions, "no_op_preserves_undo": True,
"formula_updates_still_associative": True}
def reference_dimensions():
_, _, sketch, ids = _circle("ReferenceParameters")
sketch.setDriving(ids["РадиусКруга"], False)
sketch.Document.recompute()
assert not sketch.getDriving(ids["РадиусКруга"])
rows = _rows(sketch)
assert rows["РадиусКруга"]["read_only"] and rows["РадиусКруга"]["reason"] == "Справочный размер"
before = _state(sketch)
_reject(lambda: model.set_dimensions(sketch, {"ЦентрX": -18, "РадиусКруга": 5}))
assert _state(sketch) == before
model.set_dimension(sketch, "ЦентрX", -18)
assert not sketch.getDriving(ids["РадиусКруга"])
_near(sketch.Geometry[0].Center.x, -18)
return {"reference_radius_read_only": True, "rejected_edit_is_atomic": True}
def delegated_body_parameters():
doc = model.new_part("BodyParameters")
body = model.create_box(40, 20, 10, doc)
sketch = next(member for member in body.Group if member.isDerivedFrom("Sketcher::SketchObject"))
assert set(_rows(body)) == set(_rows(sketch)), "Existing generated-body contract changed"
assert all(not row["read_only"] for row in _rows(body).values())
model.set_dimension(body, "QIHeight", 12)
_near(body.Shape.Volume, 40 * 20 * 12)
source = body.QIParameters
source.setExpression("QIWidth", "QILength / 2")
doc.recompute()
assert _rows(body)["QIWidth"]["read_only"]
formulas = (list(source.ExpressionEngine), list(body.ExpressionEngine))
model.set_dimensions(body, {"QIHeight": 15, "QIWidth": 20})
assert (list(source.ExpressionEngine), list(body.ExpressionEngine)) == formulas
_near(body.Shape.Volume, 40 * 20 * 15)
before = (source.QIWidth.Value, source.QIHeight.Value, body.Shape.Volume, doc.UndoCount)
_reject(lambda: model.set_dimensions(body, {"QIWidth": 25, "QIHeight": 18}))
assert (source.QIWidth.Value, source.QIHeight.Value, body.Shape.Volume, doc.UndoCount) == before
# The native property editor may override the Body's delegation itself.
body.setExpression("QIHeight", source.Name + ".QIWidth / 2")
doc.recompute()
assert _rows(body)["QIHeight"]["read_only"]
previous_source = source.QIHeight.Value
_reject(lambda: model.set_dimension(body, "QIHeight", 18))
_near(source.QIHeight.Value, previous_source)
return {"internal_delegation_remains_editable": True, "source_formula_read_only": True,
"overridden_body_formula_read_only": True}
def invalid_inputs_and_units():
_, _, sketch, _ = _circle("InvalidParameters")
_line(sketch)
before = _state(sketch)
for values in ({"ЦентрX": float("nan")}, {"ЦентрY": float("inf")}, {"ЦентрX": 1e20},
{"ЦентрX": True}, {"РадиусКруга": 0}, {"РадиусКруга": -1}, {"Наклон": "10 mm"},
{"ЦентрX": "30 deg"}, {"missing": 10}, {"ЦентрX": -18, "РадиусКруга": -1}):
_reject(lambda values=values: model.set_dimensions(sketch, values))
assert _state(sketch) == before
return {"invalid_numbers_units_unknown_parameters_rejected": True, "all_inputs_atomic": True}
def persisted_parameters():
doc, _, sketch, ids = _circle("PersistedParameters")
_, angle = _line(sketch)
sketch.setExpression("Constraints[%d]" % ids["РадиусКруга"], "4 mm")
sketch.setDriving(ids["ЦентрY"], False)
model.set_dimensions(sketch, {"ЦентрX": -25, "Наклон": -30})
path = output / "typed-parameters.FCStd"
doc.saveAs(str(path))
name = doc.Name
App.closeDocument(name)
restored_doc = App.openDocument(str(path))
restored = restored_doc.getObject("Sketch")
rows = _rows(restored)
_near(rows["ЦентрX"]["value"], -25)
_near(rows["Наклон"]["value"], -30)
assert rows["РадиусКруга"]["read_only"] and rows["ЦентрY"]["read_only"]
formulas = list(restored.ExpressionEngine)
model.set_dimension(restored, "Наклон", 60)
_near(restored.getDatum(angle).Value, 60)
assert list(restored.ExpressionEngine) == formulas
return {"native_project": str(path), "typed_metadata_and_formulas_survive_reopen": True}
def drawing_rejects_cached_failed_feature():
doc = model.new_part("DrawingSourceValidation")
body = model.create_box(40, 20, 10, doc)
feature = body.Tip
profile = feature.Profile
feature.Profile = None
doc.recompute()
assert "Invalid" in feature.State
assert not feature.Shape.isNull(), "Regression requires FreeCAD's cached failed Shape"
# An independent valid body must not hide the failed source body.
model.create_box(10, 10, 10, doc)
before = ({member.Name for member in doc.Objects}, doc.UndoCount)
_reject(lambda: model.create_drawing(doc))
assert ({member.Name for member in doc.Objects}, doc.UndoCount) == before
feature.Profile = profile
doc.recompute()
page = model.create_drawing(doc)
assert len(page.Views) == 3
model.prepare_drawing(page)
if App.GuiUp:
import FreeCADGui as Gui
gui_doc = Gui.getDocument(doc.Name)
if gui_doc.getInEdit():
gui_doc.resetEdit()
Gui.updateGui()
return {"cached_invalid_source_rejected_before_document_mutation": True,
"recovered_source_creates_three_native_views": True}
def gui_editor():
import FreeCADGui as Gui
from PySide import QtCore
try:
from PySide6 import QtTest
except ImportError:
from PySide2 import QtTest
try:
from PySide import QtWidgets
except ImportError:
from PySide import QtGui as QtWidgets
import qi_ui
doc, _, sketch, ids = _circle("TypedParameterDialog")
_, angle = _line(sketch)
sketch.setExpression("Constraints[%d]" % ids["ЦентрX"], "-12 mm")
sketch.setDriving(ids["РадиусКруга"], False)
doc.recompute()
Gui.Selection.clearSelection()
Gui.Selection.addSelection(sketch)
qi_ui.show_dimensions()
dialog = qi_ui._dimensions
try:
boxes = {name: dialog.findChild(QtWidgets.QDoubleSpinBox, "QI_Parameter_" + name)
for name in _rows(sketch)}
assert all(boxes.values())
assert boxes["ЦентрX"].isReadOnly() and boxes["РадиусКруга"].isReadOnly()
assert "выражением" in boxes["ЦентрX"].toolTip()
assert "Справочный" in boxes["РадиусКруга"].toolTip()
assert boxes["Наклон"].suffix().strip() == "°"
_near(boxes["Наклон"].value(), 45)
apply = dialog.findChild(QtWidgets.QDialogButtonBox).button(QtWidgets.QDialogButtonBox.Apply)
undo = (doc.UndoCount, doc.RedoCount)
length_before = sketch.getDatum(next(row["index"] for row in _rows(sketch).values()
if row["name"] == "ДлинаЛинии")).Value
# The four-decimal display must not replace an untouched precise datum.
QtTest.QTest.mouseClick(apply, QtCore.Qt.LeftButton)
assert (doc.UndoCount, doc.RedoCount) == undo
_near(_rows(sketch)["ДлинаЛинии"]["value"], length_before)
boxes["ЦентрY"].setValue(-15)
boxes["Наклон"].setValue(60)
formulas = list(sketch.ExpressionEngine)
QtTest.QTest.mouseClick(apply, QtCore.Qt.LeftButton)
_near(sketch.Geometry[0].Center.y, -15)
_near(sketch.getDatum(angle).Value, 60)
assert list(sketch.ExpressionEngine) == formulas
assert not sketch.getDriving(ids["РадиусКруга"])
assert doc.UndoCount == undo[0] + 1
QtTest.QTest.mouseClick(apply, QtCore.Qt.LeftButton)
assert doc.UndoCount == undo[0] + 1
QtWidgets.QApplication.processEvents()
screenshot = output / "typed-parameter-dialog.png"
assert dialog.grab().save(str(screenshot))
return {"screenshot": str(screenshot), "actual_apply_button": True,
"untouched_precision_preserved": True, "one_native_undo_step": True}
finally:
dialog.close()
Gui.Selection.clearSelection()
try:
for name, action in (("Signed coordinates, zero and undo redo", signed_coordinates_and_zero),
("Native degree and radian angle units", angle_units),
("Indexed and named formulas remain read only", expression_paths_and_noop),
("Native reference dimensions remain read only", reference_dimensions),
("Body VarSet delegation and user formulas", delegated_body_parameters),
("Invalid values and dimensional units rejected atomically", invalid_inputs_and_units),
("Typed parameter FCStd persistence", persisted_parameters),
("Drawing refuses cached invalid source geometry", drawing_rejects_cached_failed_feature)):
check(name, action)
if App.GuiUp:
check("Qt parameter editor preserves readonly values, precision and undo", gui_editor)
finally:
for name in set(App.listDocuments()) - initial_docs:
if App.GuiUp:
import FreeCADGui as Gui
gui_doc = Gui.getDocument(name)
if gui_doc.getInEdit():
gui_doc.resetEdit()
Gui.updateGui()
App.Console.PrintLog("QInventor parameters test: close document " + name + "\n")
App.closeDocument(name)
if active_name and active_name in App.listDocuments():
App.setActiveDocument(active_name)
report = {"passed": all(item["passed"] for item in checks), "count": len(checks),
"gui": bool(App.GuiUp), "checks": checks}
(output / "parameters-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
return report