Files
Qinventor/tests/parameter_table_suite.py
T

541 lines
33 KiB
Python
Raw 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.
"""Document-wide parameter table verified against native FreeCAD CAD kernels."""
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
import qi_parameters as parameters
def _near(actual, expected):
assert math.isclose(float(getattr(actual, "Value", actual)), expected, rel_tol=1e-9, abs_tol=1e-7), (actual, expected)
def _rows(doc):
return {row["key"]: row for row in parameters.list_document_parameters(doc)}
def _state(doc):
def native_content(obj):
# dumpContent is a ZIP whose DOS timestamps change without any CAD
# mutation. Compare every exact persisted member payload instead.
raw = bytes(obj.dumpContent())
if not zipfile.is_zipfile(io.BytesIO(raw)):
return raw
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
return tuple((name, archive.read(name)) for name in sorted(archive.namelist()))
return ([(obj.Name, native_content(obj), list(obj.ExpressionEngine), list(obj.State)) for obj in doc.Objects],
doc.UndoCount, doc.RedoCount, App.getActiveTransaction(),
App.ActiveDocument.Name if App.ActiveDocument else None, set(App.listDocuments()))
def _reject(doc, action):
before = _state(doc)
try:
action()
except ValueError as exc:
assert any("а" <= c.lower() <= "я" for c in str(exc)), str(exc)
else:
raise AssertionError("Invalid parameters were accepted")
assert _state(doc) == before, "Rejected parameter batch modified document, Undo/Redo, active operation, or document set"
def _circle(name):
doc = model.new_part(name)
body = doc.addObject("PartDesign::Body", "Body")
sketch = body.newObject("Sketcher::SketchObject", "Sketch")
sketch.addGeometry(Part.Circle(App.Vector(), App.Vector(0, 0, 1), 5), False)
sketch.addConstraint(Sketcher.Constraint("Coincident", 0, 3, -1, 1))
index = sketch.addConstraint(Sketcher.Constraint("Radius", 0, 5))
sketch.renameConstraint(index, "Радиус")
doc.recompute()
assert sketch.solve() == 0
pad = body.newObject("PartDesign::Pad", "Pad")
pad.Profile = sketch
pad.Length = 10
doc.recompute()
assert pad.Shape.isValid() and len(pad.Shape.Solids) == 1
doc.clearUndos()
return doc, body, sketch, pad
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 ""
checks = []
report_path = output / "parameter-table-report.json"
def check(name, function):
report_path.write_text(json.dumps({"checks": checks, "running_case": name}, ensure_ascii=False, indent=2), encoding="utf-8")
App.Console.PrintLog("QInventor parameter table test: " + name + "\n")
try:
evidence = function()
checks.append({"name": name, "passed": True, "evidence": evidence})
except Exception:
checks.append({"name": name, "passed": False, "error": traceback.format_exc()})
def discovery():
doc = model.new_part("TableDiscovery")
body = model.create_box(30, 20, 10, doc=doc)
body.Label = "Одинаковая подпись"
source = body.QIParameters
source.Label = "Одинаковая подпись"
sketch = body.Tip.Profile[0]
extra = doc.addObject("App::FeaturePython", "Unrelated")
extra.addProperty("App::PropertyFloat", "Temperature")
snapshot = _state(doc)
rows = _rows(doc)
assert source.Name + ":QILength" in rows
assert body.Name + ":QILength" not in rows
assert sketch.Name + ":Constraints.Длина" in rows
assert body.Tip.Name + ":Length" in rows
assert extra.Name + ":Temperature" not in rows
assert len(rows) == len(set(rows)) and _state(doc) == snapshot
source.Label = "Другой заголовок"
assert set(_rows(doc)) == set(rows)
return {"native_controller_source": True, "no_discovery_mutation": True, "label_independent_ids": True, "rows": len(rows)}
def native_user_types():
doc = model.new_part("TableTypes")
parameters.apply_document_parameters(doc, additions=[
{"name": "Size", "kind": "length", "value": "2.5 cm", "comment": "Основной размер"},
{"name": "Rotation", "kind": "angle", "value": "1.5707963267948966 rad"},
{"name": "Scale", "kind": "scalar", "value": 2},
{"name": "Offset", "kind": "length", "value": "-2,5 мм"},
{"name": "Zero", "kind": "angle", "value": "0°"}])
source = doc.getObject("QIUserParameters")
assert source.TypeId == "App::VarSet" and source.getTypeIdOfProperty("Size") == "App::PropertyDistance"
assert source.getTypeIdOfProperty("Rotation") == "App::PropertyAngle"
assert source.getTypeIdOfProperty("Scale") == "App::PropertyFloat"
_near(source.Size, 25); _near(source.Rotation, 90); _near(source.Scale, 2); _near(source.Offset, -2.5)
assert _rows(doc)[source.Name + ":Size"]["comment"] == "Основной размер"
assert doc.UndoCount == 1
doc.undo(); doc.recompute()
assert doc.getObject("QIUserParameters") is None
doc.redo(); doc.recompute()
assert doc.getObject("QIUserParameters").Size.Value == 25
return {"native_types": ["Distance (signed length)", "Angle", "Float"], "unit_conversions": True, "russian_literals": True, "one_undo_creation": True}
def add_chain_and_bind_same_batch():
doc, body, sketch, pad = _circle("TableBindings")
parameters.apply_document_parameters(doc, edits=[
{"key": "Sketch:Constraints.Радиус", "expression": "QIUserParameters.Size / 2"},
{"key": "Pad:Length", "expression": "QIUserParameters.Height"}], additions=[
{"name": "Size", "kind": "length", "value": "12 mm"},
{"name": "Height", "kind": "length", "expression": "Size * Scale"},
{"name": "Scale", "kind": "scalar", "value": 2}])
_near(sketch.Geometry[0].Radius, 6); _near(pad.Length, 24)
_near(body.Shape.Volume, math.pi * 36 * 24)
assert doc.UndoCount == 1 and sketch.solve() == 0
doc.undo(); doc.recompute()
_near(sketch.Geometry[0].Radius, 5); _near(pad.Length, 10)
assert doc.getObject("QIUserParameters") is None and not sketch.ExpressionEngine and not pad.ExpressionEngine
doc.redo(); doc.recompute()
_near(body.Shape.Volume, math.pi * 36 * 24)
parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Size", "value": 16}])
_near(body.Shape.Volume, math.pi * 64 * 32)
return {"add_bind_chain_atomic": True, "native_expressions": True, "kernel_volume_mm3": body.Shape.Volume, "undo_redo": True}
def literal_noops_and_active_operations():
doc = model.new_part("TableNoop")
body = model.create_box(30, 20, 10, doc=doc)
row = _rows(doc)[body.QIParameters.Name + ":QILength"]
before = _state(doc)
parameters.apply_document_parameters(doc, [{"key": row["key"], "value": "3 cm", "expression": "", "comment": row["comment"]}])
assert _state(doc) == before
doc.openTransaction("Чужая операция")
try:
transaction = App.getActiveTransaction()
parameters.apply_document_parameters(doc, [{"key": row["key"], "value": 30}])
assert App.getActiveTransaction() == transaction
_reject(doc, lambda: parameters.apply_document_parameters(doc, [{"key": row["key"], "value": 31}]))
body.QIParameters.QILength = 32
_reject(doc, lambda: parameters.apply_document_parameters(doc, [{"key": row["key"], "value": 35}]))
finally:
doc.abortTransaction()
if App.getActiveTransaction() is not None:
App.closeActiveTransaction()
doc.recompute()
_near(body.Shape.Volume, 6000)
return {"numeric_noop_no_undo": True, "foreign_empty_and_pending_transaction_preserved": True}
def invalid_inputs_preserve_history():
doc = model.new_part("TableValidation")
body = model.create_box(30, 20, 10, doc=doc)
key = body.QIParameters.Name + ":QILength"
parameters.apply_document_parameters(doc, [{"key": key, "value": 35}])
doc.undo(); doc.recompute()
assert doc.RedoCount == 1
rejected = 0
for value in (0, -2, True, float("nan"), float("inf"), "2 deg", "abc", "100000001 mm"):
_reject(doc, lambda value=value: parameters.apply_document_parameters(doc, [{"key": key, "value": value}]))
rejected += 1
for name in ("Size X", "1Size", "QIPreserved", "mm", "deg", "pi", "true", "None"):
_reject(doc, lambda name=name: parameters.apply_document_parameters(doc, additions=[{"name": name, "kind": "length", "value": 10}]))
rejected += 1
_reject(doc, lambda: parameters.apply_document_parameters(doc, additions=[{"name": "Rotation", "kind": "angle", "value": 450}]))
_reject(doc, lambda: parameters.apply_document_parameters(doc, [{"key": key, "value": 31}], additions=[{"name": "Size", "value": 2}, {"name": "Size", "value": 3}]))
doc.redo(); doc.recompute(); _near(body.Shape.Volume, 7000)
return {"rejected_cases": rejected + 2, "existing_redo_branch_usable": True, "native_clamping_rejected": True}
def native_unit_expression_validation():
doc = model.new_part("TableExpressionUnits")
parameters.apply_document_parameters(doc, additions=[{"name": "Size", "kind": "length", "value": 10},
{"name": "Rotation", "kind": "angle", "value": 30}, {"name": "Scale", "kind": "scalar", "value": 2}])
invalid = [("Size", "Rotation"), ("Rotation", "Size"), ("Scale", "Size"), ("Size", "Size + Rotation")]
for name, expression in invalid:
_reject(doc, lambda name=name, expression=expression: parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:" + name, "expression": expression}]))
parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Scale", "expression": "Size / 1 mm"},
{"key": "QIUserParameters:Rotation", "expression": "asin(0.5)"}])
_near(doc.getObject("QIUserParameters").Scale, 10); _near(doc.getObject("QIUserParameters").Rotation, 30)
return {"native_expression_quantity_checks": len(invalid), "native_functions_and_unit_conversion": True}
def native_bad_expressions_and_cycles():
doc = model.new_part("TableExpressionFailures")
parameters.apply_document_parameters(doc, additions=[{"name": "Size", "value": 10}, {"name": "Other", "value": 20}])
for expression in ("Missing.Parameter", "1 / 0", "Size +", "Size"):
_reject(doc, lambda expression=expression: parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Size", "expression": expression}]))
_reject(doc, lambda: parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Size", "expression": "Other"}, {"key": "QIUserParameters:Other", "expression": "Size"}]))
_reject(doc, lambda: parameters.apply_document_parameters(doc, additions=[{"name": "Broken", "expression": "Missing + 2 mm"}]))
assert doc.getObject("QIUserParameters").ExpressionEngine == []
return {"native_syntax_missing_divide_zero_self_and_cycle_rejected": True}
def safe_rename_and_comment_history():
doc, body, sketch, pad = _circle("TableRename")
parameters.apply_document_parameters(doc, edits=[{"key": "Pad:Length", "expression": "QIUserParameters.Size"}],
additions=[{"name": "Size", "value": 10, "comment": "Исходный"}, {"name": "DoubleSize", "expression": "Size * 2"}])
source = doc.getObject("QIUserParameters")
before_native_documentation = source.getDocumentationOfProperty("Size")
undo = doc.UndoCount
parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Size", "name": "Height", "comment": "Высота детали"},
{"key": "Pad:Length", "comment": "Связь с высотой"}])
assert source.getDocumentationOfProperty("Height") == before_native_documentation
assert "Height" in dict(pad.ExpressionEngine)["Length"]
assert dict(source.ExpressionEngine)["DoubleSize"] == "Height * 2"
assert _rows(doc)["QIUserParameters:Height"]["comment"] == "Высота детали"
assert _rows(doc)["Pad:Length"]["comment"] == "Связь с высотой"
assert doc.UndoCount == undo + 1
doc.undo(); doc.recompute()
assert "Size" in source.PropertiesList and "Height" not in source.PropertiesList
assert _rows(doc)["QIUserParameters:Size"]["comment"] == "Исходный"
assert _rows(doc)["Pad:Length"]["comment"] != "Связь с высотой"
doc.redo(); doc.recompute()
assert _rows(doc)["QIUserParameters:Height"]["comment"] == "Высота детали"
before_count = doc.UndoCount
parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Height", "comment": "Только комментарий"}])
assert doc.UndoCount == before_count + 1
doc.undo(); doc.recompute()
assert _rows(doc)["QIUserParameters:Height"]["comment"] == "Высота детали"
return {"native_dependency_rename": True, "comment_metadata_transactional": True, "comment_only_undo": True}
def safe_deletions_and_clear_formula():
doc, body, sketch, pad = _circle("TableDelete")
parameters.apply_document_parameters(doc, edits=[{"key": "Pad:Length", "expression": "QIUserParameters.Size"}],
additions=[{"name": "Size", "value": 15}, {"name": "DoubleSize", "expression": "Size * 2"}])
parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Size", "value": 16}])
doc.undo(); doc.recompute()
assert doc.RedoCount == 1
_reject(doc, lambda: parameters.apply_document_parameters(doc, deletions=["QIUserParameters:Size"]))
parameters.apply_document_parameters(doc, [{"key": "Pad:Length", "expression": ""},
{"key": "QIUserParameters:DoubleSize", "expression": ""}], deletions=["QIUserParameters:Size"])
assert "Size" not in doc.getObject("QIUserParameters").PropertiesList
_near(pad.Length, 15); _near(doc.getObject("QIUserParameters").DoubleSize, 30)
assert not pad.ExpressionEngine and not doc.getObject("QIUserParameters").ExpressionEngine
doc.undo(); doc.recompute()
assert "Size" in doc.getObject("QIUserParameters").PropertiesList and pad.ExpressionEngine
doc.redo(); doc.recompute()
assert "Size" not in doc.getObject("QIUserParameters").PropertiesList
_reject(doc, lambda: parameters.apply_document_parameters(doc, deletions=["Pad:Length"]))
return {"referenced_delete_rejected_with_redo": True, "native_cached_dangling_formula_detected": True,
"unbind_and_delete_same_batch": True, "clear_preserves_evaluated_value": True}
def reference_and_unnamed_constraints():
doc = model.new_part("TableConstraintKinds")
sketch = doc.addObject("Sketcher::SketchObject", "Sketch")
sketch.addGeometry(Part.LineSegment(App.Vector(), App.Vector(10, 10, 0)), False)
sketch.addConstraint(Sketcher.Constraint("Coincident", 0, 1, -1, 1))
length = sketch.addConstraint(Sketcher.Constraint("Distance", 0, math.sqrt(200)))
angle = sketch.addConstraint(Sketcher.Constraint("Angle", 0, math.pi / 4))
sketch.renameConstraint(angle, "Наклон")
ref = sketch.addConstraint(Sketcher.Constraint("DistanceX", 0, 1, 0, 2, 10))
sketch.setDriving(ref, False)
doc.recompute(); assert sketch.solve() == 0
rows = _rows(doc)
length_key = "Sketch:Constraints[%d]" % length
ref_key = "Sketch:Constraints[%d]" % ref
assert length_key in rows and rows[ref_key]["reference"] and not rows[ref_key]["editable"]
parameters.apply_document_parameters(doc, [{"key": length_key, "value": "20 mm"},
{"key": "Sketch:Constraints.Наклон", "expression": "30 deg"}])
_near(sketch.getDatum(angle), 30); _near(sketch.getDatum(length), 20)
_near(sketch.Geometry[0].EndPoint.x, 20 * math.cos(math.pi / 6))
_reject(doc, lambda: parameters.apply_document_parameters(doc, [{"key": ref_key, "expression": "10 mm"}]))
return {"unnamed_native_paths": True, "degrees_not_radians": True, "reference_readonly": True}
def solver_and_cached_shape_failure():
doc = model.new_part("TableSolverFailure")
sketch = doc.addObject("Sketcher::SketchObject", "Sketch")
sketch.addGeometry(Part.LineSegment(App.Vector(), App.Vector(8, 6, 0)), False)
sketch.addConstraint(Sketcher.Constraint("Coincident", 0, 1, -1, 1))
length = sketch.addConstraint(Sketcher.Constraint("Distance", 0, 10))
x = sketch.addConstraint(Sketcher.Constraint("DistanceX", 0, 1, 0, 2, 8))
doc.recompute(); assert sketch.solve() == 0
doc.clearUndos()
parameters.apply_document_parameters(doc, [{"key": "Sketch:Constraints[%d]" % x, "value": 7}])
doc.undo(); doc.recompute(); assert doc.RedoCount == 1
_reject(doc, lambda: parameters.apply_document_parameters(doc, [{"key": "Sketch:Constraints[%d]" % x, "value": 12}]))
doc2 = model.new_part("TableCachedShapeFailure")
body = model.create_box(30, 20, 10, doc=doc2)
pad = body.Tip
fillet = body.newObject("PartDesign::Fillet", "Fillet")
fillet.Base = (pad, ["Edge1"])
fillet.Radius = 1
doc2.recompute(); assert fillet.Shape.isValid() and fillet.Shape.Solids and "Invalid" not in fillet.State
parameters.apply_document_parameters(doc2, [{"key": "Fillet:Radius", "value": 2}])
doc2.undo(); doc2.recompute(); assert doc2.RedoCount == 1
_reject(doc2, lambda: parameters.apply_document_parameters(doc2, [{"key": "Fillet:Radius", "value": 100}]))
return {"native_unsolvable_constraints_rejected": True, "native_invalid_feature_with_cached_valid_shape_rejected": True,
"rejected_kernel_edits_preserve_redo": True}
def persistence():
doc, body, sketch, pad = _circle("TablePersistence")
parameters.apply_document_parameters(doc, [{"key": "Pad:Length", "expression": "QIUserParameters.Height"},
{"key": "Sketch:Constraints.Радиус", "expression": "QIUserParameters.Size / 2"}], additions=[
{"name": "Size", "value": 14, "comment": "Диаметр"}, {"name": "Height", "expression": "Size * 2"}])
parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Size", "comment": "Диаметр цилиндра"}])
file = output / "document-parameters.FCStd"
doc.saveAs(str(file))
expected = _rows(doc)
name = doc.Name
App.closeDocument(name)
doc = App.openDocument(str(file))
actual = _rows(doc)
assert set(actual) == set(expected)
for key in expected:
assert actual[key]["expression"] == expected[key]["expression"]
assert actual[key]["comment"] == expected[key]["comment"]
_near(actual[key]["value"], expected[key]["value"])
parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Size", "value": 16}])
_near(doc.getObject("Body").Shape.Volume, math.pi * 64 * 32)
return {"file": str(file), "native_saved_formulas_comments_values": True, "reopened_edit_rebuilds_solid": True}
def new_document_qualified_expressions():
doc = model.new_part("TableQualifiedExpressions")
body = model.create_box(30, 20, 10, doc=doc)
parameters.apply_document_parameters(doc, additions=[{"name": "Size", "value": 2}, {"name": "Other", "value": 5}])
parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Other", "value": 6}])
doc.undo(); doc.recompute()
doc.saveAs(str(output / "self-qualified-expression.FCStd"))
assert doc.RedoCount == 1
_reject(doc, lambda: parameters.apply_document_parameters(doc,
[{"key": "QIUserParameters:Size", "value": 0},
{"key": body.QIParameters.Name + ":QIHeight", "expression": doc.Name + "#QIUserParameters.Size"}]))
parameters.apply_document_parameters(doc,
[{"key": "QIUserParameters:Size", "value": 4},
{"key": body.QIParameters.Name + ":QIHeight", "expression": doc.Name + "#QIUserParameters.Size"}])
_near(body.QIParameters.QIHeight, 4); _near(body.Shape.Volume, 2400)
# Qualified quoted labels are native references, not string literals.
doc.getObject("QIUserParameters").Label = "Параметры проекта"
parameters.apply_document_parameters(doc,
[{"key": "QIUserParameters:Size", "value": 5},
{"key": body.QIParameters.Name + ":QIHeight", "expression": "<<" + doc.Label + ">>#<<Параметры проекта>>.Size"}])
_near(body.Shape.Volume, 3000)
# FreeCAD preserves Cyrillic internal document and object names. Those
# unquoted native identifiers must remap just like ASCII names.
unicode_doc = App.newDocument("Документ")
unicode_doc.UndoMode = 1
native = unicode_doc.addObject("App::VarSet", "Параметры")
native.addProperty("App::PropertyBool", "QIUserParameterSet")
native.QIUserParameterSet = True
native.addProperty("App::PropertyDistance", "Size")
native.Size = 2
box = unicode_doc.addObject("Part::Box", "Брусок")
box.Length, box.Width, box.Height = 10, 2, 3
unicode_doc.recompute()
unicode_doc.clearUndos()
parameters.apply_document_parameters(unicode_doc, [{"key": "Параметры:Size", "value": 4}])
unicode_doc.undo(); unicode_doc.recompute()
unicode_doc.saveAs(str(output / "unicode-qualified-expression.FCStd"))
assert unicode_doc.RedoCount == 1
_reject(unicode_doc, lambda: parameters.apply_document_parameters(unicode_doc,
[{"key": "Параметры:Size", "value": 0},
{"key": "Брусок:Height", "expression": "Документ#Параметры.Size"}]))
parameters.apply_document_parameters(unicode_doc,
[{"key": "Параметры:Size", "value": 5},
{"key": "Брусок:Height", "expression": "Документ#Параметры.Size"}])
_near(box.Shape.Volume, 100)
return {"new_qualified_expression_reads_proposed_values": True, "rejected_qualified_expression_preserves_redo": True,
"native_quoted_document_and_object_labels": True, "raw_cyrillic_document_and_object_identifiers": True}
def cross_document_native_dependencies():
source = model.new_part("TableExternalSource")
parameters.apply_document_parameters(source, additions=[{"name": "Size", "value": 10}])
consumer = model.new_part("TableExternalConsumer")
box = consumer.addObject("Part::Box", "Box")
box.Length, box.Width, box.Height = 10, 2, 3
# External document also owns a user table: native clone preflight must
# preserve both sets without treating the secondary table as editable.
parameters.apply_document_parameters(consumer, additions=[{"name": "Other", "value": 20}])
source.saveAs(str(output / "external-source.FCStd"))
consumer.saveAs(str(output / "external-consumer.FCStd"))
box.setExpression("Length", source.Name + "#QIUserParameters.Size")
consumer.recompute()
source.clearUndos(); consumer.clearUndos()
parameters.apply_document_parameters(source, [{"key": "QIUserParameters:Size", "value": 15}])
_near(box.Shape.Volume, 90)
assert source.UndoCount == 1 and consumer.UndoCount == 1
source.undo(); source.recompute(); consumer.recompute()
_near(box.Shape.Volume, 60)
source_state, consumer_state = _state(source), _state(consumer)
try:
parameters.apply_document_parameters(source, deletions=["QIUserParameters:Size"])
except ValueError:
pass
else:
raise AssertionError("External consumer reference deleted")
assert _state(source) == source_state and _state(consumer) == consumer_state
source.redo(); source.recompute(); consumer.recompute()
_near(box.Shape.Volume, 90)
parameters.apply_document_parameters(source, [{"key": "QIUserParameters:Size", "name": "Length"}])
assert "Length" in dict(box.ExpressionEngine)["Length"]
_near(box.Shape.Volume, 90)
return {"external_consumers_recomputed": True, "native_global_undo_redo": True,
"both_document_redo_preserved_after_rejected_delete": True, "external_rename_dependency_preserved": True}
def real_document_graph_and_saved_requirements():
initial_source = model.new_part("TableFirstExternalVariable")
initial_external = model.new_part("TableFirstExternalTable")
parameters.apply_document_parameters(initial_external, additions=[{"name": "Width", "value": 4}])
initial_source.saveAs(str(output / "first-external-source.FCStd"))
initial_external.saveAs(str(output / "first-external-target.FCStd"))
parameters.apply_document_parameters(initial_source, additions=[
{"name": "Size", "expression": initial_external.Name + "#QIUserParameters.Width"}])
assert initial_source.getObject("QIUserParameters") is not None
_near(initial_source.getObject("QIUserParameters").Size, 4)
source = model.new_part("TableSaveRulesSource")
parameters.apply_document_parameters(source, additions=[{"name": "Size", "value": 2}, {"name": "Other", "value": 5}])
external = model.new_part("TableSaveRulesExternal")
native = external.addObject("App::VarSet", "External")
native.addProperty("App::PropertyDistance", "Width")
native.Width = 4
external.saveAs(str(output / "save-rules-external.FCStd"))
parameters.apply_document_parameters(source, [{"key": "QIUserParameters:Other", "value": 6}])
source.undo(); source.recompute()
assert source.RedoCount == 1
_reject(source, lambda: parameters.apply_document_parameters(source,
[{"key": "QIUserParameters:Other", "value": 4},
{"key": "QIUserParameters:Size", "expression": external.Name + "#External.Width"}]))
source.saveAs(str(output / "save-rules-source.FCStd"))
parameters.apply_document_parameters(source,
[{"key": "QIUserParameters:Size", "expression": external.Name + "#External.Width"}])
_near(source.getObject("QIUserParameters").Size, 4)
parameters.apply_document_parameters(source, [{"key": "QIUserParameters:Size", "expression": "", "value": 2}])
external_native = external.addObject("App::VarSet", "Downstream")
external_native.addProperty("App::PropertyDistance", "Height")
external_native.setExpression("Height", source.Name + "#QIUserParameters.Other * 2")
external.recompute()
parameters.apply_document_parameters(source, [{"key": "QIUserParameters:Size", "value": 3}])
source.undo(); source.recompute(); external.recompute()
assert source.RedoCount == 1
_reject(source, lambda: parameters.apply_document_parameters(source,
[{"key": "QIUserParameters:Size", "expression": external.Name + "#External.Width"}]))
unsaved = model.new_part("TableUnsavedReference")
unsaved_native = unsaved.addObject("App::VarSet", "Unsaved")
unsaved_native.addProperty("App::PropertyDistance", "Width"); unsaved_native.Width = 7
_reject(source, lambda: parameters.apply_document_parameters(source,
[{"key": "QIUserParameters:Size", "expression": unsaved.Name + "#Unsaved.Width"}]))
return {"real_unsaved_owner_and_target_rejected_before_mutation": True,
"valid_saved_external_binding": True, "object_acyclic_document_cycle_rejected": True, "redo_preserved": True,
"first_external_bound_user_parameter_preserves_source_name": True}
def generated_plate_rules_and_grouped_drawing():
doc = model.new_part("TablePlateRules")
body = model.create_plate_with_holes(60, 30, 4, 6, 40, doc)
key = body.QIParameters.Name + ":QIHoleDiameter"
parameters.apply_document_parameters(doc, [{"key": key, "value": 8}])
doc.undo(); doc.recompute(); assert doc.RedoCount == 1
_reject(doc, lambda: parameters.apply_document_parameters(doc, [{"key": key, "value": 30}]))
_reject(doc, lambda: parameters.apply_document_parameters(doc,
[{"key": body.QIParameters.Name + ":QIHoleSpacing", "expression": "QIUserParameters.Spacing"}],
additions=[{"name": "Spacing", "value": 2}]))
page = model.create_drawing(doc)
group = doc.addObject("App::DocumentObjectGroup", "DrawingGroup")
group.Group = [body, page]
doc.recompute()
if App.GuiUp:
import FreeCADGui as Gui
model.prepare_drawing(page)
Gui.getDocument(doc.Name).resetEdit()
copied_types = []
native_batch = parameters._native_batch
def observe(current, plan):
if current.Name.startswith("QIParameterValidation"):
copied_types.extend(obj.TypeId for obj in current.Objects)
assert all(not obj.TypeId.startswith("TechDraw::") for obj in current.Objects)
assert len(current.getObject("DrawingGroup").Group) == 1
return native_batch(current, plan)
parameters._native_batch = observe
try:
parameters.apply_document_parameters(doc, [{"key": key, "value": 8}])
finally:
parameters._native_batch = native_batch
if App.GuiUp:
model.prepare_drawing(page)
Gui.getDocument(doc.Name).resetEdit()
assert copied_types and group.Group == [body, page]
assert body.Shape.isValid() and len(body.Shape.Solids) == 1
return {"generated_hole_fit_rules_preserved": True, "group_does_not_reimport_techdraw_in_clone": True,
"real_page_and_group_preserved": True, "clone_cad_objects": len(copied_types)}
cases = [("Discover native model sources and stable identities", discovery),
("Native user parameter types and Russian literals", native_user_types),
("Create chain and model bindings in one batch", add_chain_and_bind_same_batch),
("No-op and foreign CAD transactions", literal_noops_and_active_operations),
("Reject invalid inputs without losing existing redo", invalid_inputs_preserve_history),
("Validate native expression dimensions", native_unit_expression_validation),
("Native syntax missing references and cycles", native_bad_expressions_and_cycles),
("Native rename and transactional comments", safe_rename_and_comment_history),
("Referenced delete and clear formulas", safe_deletions_and_clear_formula),
("Unnamed angular and reference constraints", reference_and_unnamed_constraints),
("Native solver and cached shape errors", solver_and_cached_shape_failure),
("FCStd persistence and continued parametric rebuild", persistence),
("New native document-qualified expressions", new_document_qualified_expressions),
("Cross-document consumers and global Undo", cross_document_native_dependencies)]
cases.extend([("Real document cycles and FCStd requirements", real_document_graph_and_saved_requirements),
("Generated plate rules and grouped native drawing", generated_plate_rules_and_grouped_drawing)])
try:
for name, function in cases:
check(name, function)
finally:
if App.getActiveTransaction() is not None:
App.closeActiveTransaction()
for name in set(App.listDocuments()) - initial_docs:
App.closeDocument(name)
if active_name and App.getDocument(active_name):
App.setActiveDocument(active_name)
report = {"passed": all(check["passed"] for check in checks), "count": len(checks), "checks": checks,
"summary": {"total": len(checks), "passed": sum(check["passed"] for check in checks),
"failed": sum(not check["passed"] for check in checks)}, "gui_exercised": False}
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
return report
if __name__ == "__main__":
result = run(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).resolve().parents[1] / "artifacts" / "parameter-table")
print(json.dumps(result["summary"]))
raise SystemExit(0 if result["passed"] else 1)