"""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)