"""Real native associative drawing BOM and persistence regressions.""" import hashlib import json import os from pathlib import Path import subprocess import sys import tempfile import traceback import FreeCAD as App import TechDraw ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src" / "QInventor")) import qi_model as model import qi_drawing_bom as service import bom_suite as kernel def _public(plan): return {key: value for key, value in plan.items() if key not in ("sources", "geometry_views")} def _fixture(name, flexible=True): values = kernel.nested_fixture(name, flexible=flexible) doc = values[0] return values, model.create_drawing(doc) class _TracingProxy(service.DrawingBOMProxy): def __init__(self, sheet): self.depth = self.maximum_depth = self.calls = 0 self.returns = [] super().__init__(sheet) def execute(self, sheet): self.depth += 1 self.maximum_depth = max(self.maximum_depth, self.depth) self.calls += 1 try: answer = super().execute(sheet) self.returns.append(answer) return answer finally: self.depth -= 1 def reopen_check(file, output): output = Path(output) report = {"passed": False, "gui_up": bool(App.GuiUp), "service_path": service.__file__, "service_sha256": hashlib.sha256(Path(service.__file__).read_bytes()).hexdigest()} try: assert not App.GuiUp doc = App.openDocument(str(file)) doc.UndoMode = 1 page = doc.getObject("Drawing") view = service.table_for_page(page) assert view.TypeId == "TechDraw::DrawViewSpreadsheet" assert view.Source.TypeId == "Spreadsheet::SheetPython" assert isinstance(view.Source.Proxy, service.DrawingBOMProxy) doc.recompute() original = service.validate_table(page) kernel._near(original["summary"]["mass_kg"], .006342) assert view.Source.get("H2") == "Сохраняемое примечание" bolt = next(obj for obj in doc.Objects if getattr(obj, "QIPartNumber", "") == "BOLT") model.set_dimensions(bolt, {"QIHeight": 8}) updated = service.validate_table(page) kernel._near(updated["summary"]["mass_kg"], .007284) assert "0,007284" in view.Symbol doc.undo(); doc.recompute() kernel._near(service.validate_table(page)["summary"]["mass_kg"], .006342) assert doc.RedoCount == 1 doc.redo(); doc.recompute() kernel._near(service.validate_table(page)["summary"]["mass_kg"], .007284) report.update(passed=True, restored_proxy=type(view.Source.Proxy).__name__, initial=_public(original), edited=_public(updated), undo_redo=True) except Exception: report["error"] = traceback.format_exc() finally: for name in list(App.listDocuments()): App.closeDocument(name) output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") return report def _reopen_child(file, output): directory = Path(tempfile.mkdtemp(prefix="drawing-bom-reopen-", dir=str(output))) macro = directory / "reopen.FCMacro" report_path = directory / "report.json" macro.write_text("import sys\nsys.path.insert(0," + repr(str(Path(__file__).parent)) + ")\n" "import drawing_bom_suite\ndrawing_bom_suite.reopen_check(" + repr(str(file)) + "," + repr(str(report_path)) + ")\n", encoding="utf-8") engine = Path(App.getHomePath()) / "bin" / "FreeCADCmd.exe" args = [str(engine), "--user-cfg", str(directory / "user.cfg"), "--system-cfg", str(directory / "system.cfg"), "--log-file", str(directory / "native.log"), str(macro)] environment = os.environ.copy() environment.pop("QI_TEST", None) options = {} if os.name == "nt": options["creationflags"] = subprocess.CREATE_NO_WINDOW startup = subprocess.STARTUPINFO() startup.dwFlags |= subprocess.STARTF_USESHOWWINDOW startup.wShowWindow = subprocess.SW_HIDE options["startupinfo"] = startup with (directory / "process.log").open("wb") as stream: process = subprocess.Popen(args, cwd=str(directory), env=environment, stdin=subprocess.DEVNULL, stdout=stream, stderr=subprocess.STDOUT, **options) try: code = process.wait(timeout=90) except subprocess.TimeoutExpired: process.kill(); process.wait(timeout=10) raise AssertionError("Owned native FCStd reopen timed out: " + str(directory)) assert code == 0 and report_path.is_file(), (code, str(directory)) report = json.loads(report_path.read_text(encoding="utf-8")) assert report["passed"] and not report["gui_up"], report assert report["service_sha256"] == hashlib.sha256(Path(service.__file__).read_bytes()).hexdigest() report.update(native_exit_code=code, report_path=str(report_path), engine=str(engine), native_pid=process.pid) return report def run(output_dir): output = Path(output_dir).absolute() output.mkdir(parents=True, exist_ok=True) report_path = output / "drawing-bom-report.json" initial_docs = set(App.listDocuments()) checks = [] def check(name, function): report_path.write_text(json.dumps({"running_case": name, "checks": checks}, ensure_ascii=False, indent=2), encoding="utf-8") try: checks.append({"name": name, "passed": True, "evidence": function()}) except Exception: checks.append({"name": name, "passed": False, "error": traceback.format_exc()}) def native_creation_scope_and_dag(): (doc, root, bolt, housing, sub, a, b, direct), page = _fixture("DrawingBOMNative") before = kernel._state(doc) draft = service.table_snapshot(page) assert draft["sheet_name"] is None and draft["view_name"] is None assert kernel._state(doc) == before, "Draft changed real native document/history" start_undo = doc.UndoCount view = service.create_bom_table(page) assert doc.UndoCount == start_undo + 1 sheet = view.Source assert sheet.TypeId == "Spreadsheet::SheetPython" and isinstance(sheet.Proxy, service.DrawingBOMProxy) assert sheet not in root.Group and page not in sheet.OutList assert set(sheet.QIBOMGeometryViews) == set(v for v in page.Views if v.TypeId == "TechDraw::DrawViewPart") assert not any(obj.TypeId == "TechDraw::DrawPage" for obj in sheet.OutList) plan = service.validate_table(page) assert {row["part_number"]: row["quantity"] for row in plan["rows"]} == {"BOLT": 5, "HOUSING": 2} kernel._near(plan["summary"]["mass_kg"], .006342) assert "0,006342" in view.Symbol and "0,000942" in view.Symbol assert all("Invalid" not in obj.State for obj in (sheet, view)) before = kernel._state(doc) assert service.update_bom_table(page) is view doc.recompute() assert kernel._state(doc) == before, "No-op changed table/history" return {"native_types": [sheet.TypeId, view.TypeId], "draft_is_read_only": True, "one_create_undo": True, "no_page_backlink": True, "snapshot": _public(plan)} def source_geometry_labels_formulas_and_undo(): (doc, root, bolt, housing, sub, a, b, direct), page = _fixture("DrawingBOMUpdate") view = service.create_bom_table(page) sheet = view.Source sheet.set("H3", "3.5") sheet.set("H1", "=H3*2") sheet.set("H2", "'Сохраняемое примечание") sheet.setStyle("H2", "bold", "add") tracer = _TracingProxy(sheet) doc.recompute() assert float(sheet.get("H1")) == 7 initial_symbol = view.Symbol model.set_dimensions(bolt, {"QIHeight": 8}) kernel._near(service.validate_table(page)["summary"]["mass_kg"], .007284) assert view.Symbol != initial_symbol and "0,007284" in view.Symbol assert float(sheet.get("H1")) == 7 doc.undo(); doc.recompute() kernel._near(service.validate_table(page)["summary"]["mass_kg"], .006342) assert doc.RedoCount == 1 before = kernel._state(doc) doc.recompute() assert kernel._state(doc) == before doc.redo(); doc.recompute() kernel._near(service.validate_table(page)["summary"]["mass_kg"], .007284) doc.openTransaction("Обозначение и имя") bolt.Label = "=Болт" bolt.QIPartNumber = "=BOLT" doc.recompute(); doc.commitTransaction() plan = service.validate_table(page) row_index = next(index for index, row in enumerate(plan["rows"], 2) if row["name"] == "=Болт") assert sheet.get("C" + str(row_index)) == "=Болт" assert sheet.get("B" + str(row_index)) == "=BOLT" assert sheet.getContents("C" + str(row_index)).startswith("'=") assert "=Болт" in view.Symbol assert sheet.get("H2") == "Сохраняемое примечание" assert tracer.maximum_depth == 1 and tracer.calls >= 3 and all(answer is False for answer in tracer.returns) service.DrawingBOMProxy(sheet); doc.recompute() return {"automatic_source_edit": True, "native_formula_outside_generated_range": sheet.get("H1"), "callback_max_depth": tracer.maximum_depth, "all_execute_returns_false": True, "label_only_observer_and_literal_formula_prefix": True, "undo_redo_preserved": True} def scope_rebind_and_unrelated_invalid_body(): (doc, root, bolt, housing, sub, a, b, direct), page = _fixture("DrawingBOMScope") view = service.create_bom_table(page) previous = view.Source.QIBOMCells selected = model.create_box(3, 4, 5, doc) model.set_material(selected, "Алюминий") other = model.create_box(5, 6, 7, doc) other.Tip.setExpression("Length", None); other.Tip.Length = 0 doc.recompute() assert "Invalid" in other.Tip.State assert service.validate_table(page)["summary"]["components"] == 7 geometries = [obj for obj in page.Views if obj.TypeId == "TechDraw::DrawViewPart"] doc.openTransaction("Источники проекций") for obj in geometries: obj.Source = [selected] doc.recompute(); doc.commitTransaction() kernel._reject(lambda: service.validate_table(page)) assert view.Source.QIBOMCells == previous assert service.update_bom_table(page) is view plan = service.validate_table(page) assert plan["source_names"] == [selected.Name] and plan["summary"]["components"] == 1 assert plan["summary"]["materials"] == ["Алюминий"] assert page.Template.EditableTexts["Material"] == "Алюминий" before = kernel._state(doc) geometries[0].Source = [bolt] # Capture after the authorized native source edit; service rejection # must not open another transaction or replace the last good cells. before = kernel._state(doc) kernel._reject(lambda: service.update_bom_table(page)) assert kernel._state(doc) == before return {"explicit_rebind_repairs_old_sheet_invalid": True, "unrelated_invalid_body_ignored": True, "mixed_projection_sources_rejected_before_mutation": True, "material_scope": "Алюминий"} def last_good_error_and_nonhealing_validation(): (doc, root, bolt, housing, sub, a, b, direct), page = _fixture("DrawingBOMErrors") view = service.create_bom_table(page) sheet = view.Source original_cells, original_data = sheet.QIBOMCells, sheet.QIBOMData bolt.QIDensity = 0; doc.recompute() assert "Invalid" in sheet.State and sheet.QIBOMError assert sheet.QIBOMCells == original_cells and sheet.QIBOMData == original_data kernel._reject(lambda: service.validate_table(page)) bolt.QIDensity = 7850; doc.recompute() service.validate_table(page) sheet.set("F2", "'999") bad = sheet.getContents("F2") kernel._reject(lambda: service.validate_table(page)) assert sheet.getContents("F2") == bad, "Validation silently healed cells" service.update_bom_table(page) assert sheet.QIBOMCells == original_cells pad = bolt.Tip pad.setExpression("Length", None); pad.Length = 0 doc.recompute() assert "Invalid" in pad.State and pad.Shape.isValid() assert "Invalid" in pad.State kernel._reject(lambda: service.validate_table(page)) assert sheet.QIBOMCells == original_cells return {"density_error_native_invalid": True, "cached_valid_pad_rejected": True, "last_good_cells_preserved": True, "validate_does_not_heal": True} def structured_mode_and_overflow_redo(): (doc, root, bolt, housing, sub, a, b, direct), page = _fixture("DrawingBOMStructured", flexible=False) view = service.create_bom_table(page, "structured") plan = service.validate_table(page) parent = next(row for row in plan["rows"] if row["is_assembly"]) assert parent["quantity"] == parent["quantity_total"] == 2 child = next(row for row in plan["rows"] if row["part_number"] == "BOLT" and row["parent_position"]) assert child["quantity"] == 2 and child["quantity_total"] == 4 assert "Смеш." in view.Symbol and parent["material"] == "Смешанные материалы" sheet = view.Source structured_e3 = sheet.get("E3") assert structured_e3 == "4" and sheet.getTypeIdOfProperty("E3") == "App::PropertyString" assert sheet.getTypeIdOfProperty("D3") == "App::PropertyInteger" service.update_bom_table(page, "flat") flat_e3 = sheet.get("E3") assert flat_e3 == "Алюминий" and sheet.getTypeIdOfProperty("E3") == "App::PropertyString" doc.undo() # Inspect the real evaluated cell before base/native recompute can # recreate a property lost by an unsafe Spreadsheet type restoration. assert str(sheet.QIBOMMode) == "structured" and sheet.get("E3") == structured_e3 assert sheet.getTypeIdOfProperty("E3") == "App::PropertyString" and doc.RedoCount == 1 doc.recompute() service.validate_table(page) doc.redo() assert str(sheet.QIBOMMode) == "flat" and sheet.get("E3") == flat_e3 assert sheet.getTypeIdOfProperty("E3") == "App::PropertyString" doc.recompute() service.validate_table(page) for index in range(2): extra = model.create_box(1 + index, 2, 3, doc) extra.Label = "Шайба" + str(index) extra.QIPartNumber = "EXTRA" + str(index) doc.getObject("Definitions").addObject(extra) kernel._link(sub, extra, "Extra" + str(index), 30 + index * 10) doc.recompute() assert len(service.validate_table(page)["rows"]) == 4 model.set_dimensions(bolt, {"QIHeight": 8}) doc.undo(); doc.recompute() assert doc.RedoCount == 1 before = kernel._state(doc) kernel._reject(lambda: service.update_bom_table(page, "structured")) assert kernel._state(doc) == before and doc.RedoCount == 1 doc.redo(); doc.recompute() assert len(service.validate_table(page)["rows"]) == 4 return {"structured_local_total_quantities": True, "mixed_material_print_abbreviation": "Смеш.", "stable_E_cell_type_immediate_native_undo_redo": True, "D_quantity_native_integer": True, "quantity_total_metadata_numeric": True, "native_overflow_reject_preserves_redo_branch": True} def rows_removed_and_user_notes_preserved(): (doc, root, bolt, housing, sub, a, b, direct), page = _fixture("DrawingBOMRows", flexible=False) view = service.create_bom_table(page) sheet = view.Source sheet.set("H2", "'Сохраняемое примечание") doc.recompute() occurrence = doc.getObject("HousingOccurrence") sub.removeObject(occurrence) doc.recompute() model.prepare_drawing(page) plan = service.validate_table(page) assert len(plan["rows"]) == 1 and plan["rows"][0]["quantity"] == 5 assert view.CellEnd == "F3" and sheet.getContents("F4") == "" assert sheet.get("H2") == "Сохраняемое примечание" sub.addObject(occurrence); doc.recompute(); model.prepare_drawing(page) assert len(service.validate_table(page)["rows"]) == 2 and view.CellEnd == "F4" assert sheet.get("H2") == "Сохраняемое примечание" return {"removed_computed_rows_cleared": True, "printed_range_updates": True, "outside_notes_preserved": True} def top_root_membership_single_native_recompute(): (doc, root, bolt, housing, sub, a, b, direct), page = _fixture("DrawingBOMRootMembership", flexible=False) view = service.create_bom_table(page) sheet = view.Source assert sheet.QIBOMSources == [root] and all(v.Source == [root] for v in sheet.QIBOMGeometryViews) tracer = _TracingProxy(sheet) original_calls = tracer.calls doc.openTransaction("Remove actual root occurrence") root.removeObject(a) doc.recompute() doc.commitTransaction() assert tracer.calls == original_calls + 1 and tracer.maximum_depth == 1 assert len(root.Shape.Solids) == 4 reduced = service._build(sheet.QIBOMGeometryViews, "flat", sheet.QIBOMSources, False) assert reduced["summary"]["components"] == 4 assert {row["part_number"]: row["quantity"] for row in reduced["rows"]} == {"BOLT": 3, "HOUSING": 1} assert sheet.QIBOMData == service._data(reduced) assert all(sheet.getContents(key) == value for key, value in reduced["cells"].items()) service._validate_symbol(view, reduced) original_calls = tracer.calls doc.openTransaction("Restore actual root occurrence") root.addObject(a) doc.recompute() doc.commitTransaction() assert tracer.calls == original_calls + 1 and len(root.Shape.Solids) == 7 restored = service._build(sheet.QIBOMGeometryViews, "flat", sheet.QIBOMSources, False) assert restored["summary"]["components"] == 7 and sheet.QIBOMData == service._data(restored) service._validate_symbol(view, restored) model.prepare_drawing(page) service.validate_table(page) service.DrawingBOMProxy(sheet) return {"actual_projection_source": root.Name, "native_solid_counts": [7, 4, 7], "sheet_components": [7, reduced["summary"]["components"], restored["summary"]["components"]], "one_sheet_execute_per_native_recompute": True, "fresh_native_symbol": True} def insertion_undo_and_fresh_process_restore(): (doc, root, bolt, housing, sub, a, b, direct), page = _fixture("DrawingBOMPersist") start = doc.UndoCount view = service.create_bom_table(page) sheet_name, view_name = view.Source.Name, view.Name assert doc.UndoCount == start + 1 doc.undo(); doc.recompute() assert doc.getObject(sheet_name) is None and doc.getObject(view_name) is None assert service.table_for_page(page) is None and len(page.Views) == 3 assert doc.RedoCount == 1 doc.redo(); doc.recompute() view = service.table_for_page(page) assert isinstance(view.Source.Proxy, service.DrawingBOMProxy) service.validate_table(page) view.Source.set("H2", "'Сохраняемое примечание"); doc.recompute() file = output / "drawing-bom-associative.FCStd" model.save_project(doc, file) return {"create_undo_removes_sheet_and_view": True, "redo_restores_proxy": True, "fcstd": str(file), "fresh_native_process": _reopen_child(file, output)} def ordinary_mass_precision_and_label_overflow(): doc = model.new_part("DrawingBOMCylinder") source = model.create_cylinder(3, 7, doc) page = model.create_drawing(doc) view = service.create_bom_table(page) plan = service.validate_table(page) assert "0," in view.Source.get("F2") and len(view.Source.get("F2")) <= 13 assert view.Source.get("F2") != "0,00" saved = view.Source.QIBOMCells source.Label = "W" * 200 doc.recompute() assert "Invalid" in view.Source.State and view.Source.QIBOMCells == saved before = kernel._state(doc) kernel._reject(lambda: service.update_bom_table(page)) assert kernel._state(doc) == before return {"ordinary_cylinder_mass_printed": plan["cells"]["F2"], "oversized_text_rejected_without_truncation": True} def native_table_format_position_and_source_guards(): (doc, root, bolt, housing, sub, a, b, direct), page = _fixture("DrawingBOMTableGuards") view = service.create_bom_table(page) cells = view.Source.QIBOMCells doc.openTransaction("Move table outside printed area") view.X = 10 doc.recompute() doc.commitTransaction() before = kernel._state(doc) kernel._reject(lambda: service.validate_table(page)) assert kernel._state(doc) == before and view.Source.QIBOMCells == cells service.update_bom_table(page) service.validate_table(page) doc.openTransaction("Change native table font") view.TextSize = 2 doc.recompute() doc.commitTransaction() before = kernel._state(doc) kernel._reject(lambda: service.validate_table(page)) assert kernel._state(doc) == before service.update_bom_table(page) service.validate_table(page) doc.openTransaction("Rotate native table") view.Rotation = 30 doc.recompute() doc.commitTransaction() before = kernel._state(doc) kernel._reject(lambda: service.validate_table(page)) assert kernel._state(doc) == before service.update_bom_table(page) service.validate_table(page) assert float(view.Rotation) == 0 sheet = view.Source doc.openTransaction("Remove table source") view.Source = None doc.recompute() doc.commitTransaction() before = kernel._state(doc) kernel._reject(lambda: service.table_for_page(page)) kernel._reject(lambda: service.validate_table(page)) assert kernel._state(doc) == before and sheet.QIBOMCells == cells doc.undo(); doc.recompute() assert service.table_for_page(page) is view and view.Source is sheet service.validate_table(page) assert doc.RedoCount == 1 return {"actual_native_xy_checked": True, "native_font_change_rejected_and_repaired": True, "native_rotation_rejected_and_repaired": True, "lost_sheet_source_not_treated_as_absent_table": True, "validation_read_only": True, "native_undo_restores_source": True} cases = [("Native table explicit projection scope DAG and no-op", native_creation_scope_and_dag), ("Automatic source labels native formulas and Undo Redo", source_geometry_labels_formulas_and_undo), ("Explicit scope repair and unrelated invalid body", scope_rebind_and_unrelated_invalid_body), ("Native invalid last-good cells and nonhealing validation", last_good_error_and_nonhealing_validation), ("Structured quantities and overflow preserving redo", structured_mode_and_overflow_redo), ("Changing row count preserves user notes", rows_removed_and_user_notes_preserved), ("Native top-root membership updates in one recompute", top_root_membership_single_native_recompute), ("Insert Undo Redo and FCStd fresh native process", insertion_undo_and_fresh_process_restore), ("Ordinary mass precision and untruncated text errors", ordinary_mass_precision_and_label_overflow), ("Actual native table format position and missing-source guards", native_table_format_position_and_source_guards)] try: for name, function in cases: check(name, function) finally: for name in set(App.listDocuments()) - initial_docs: App.closeDocument(name) report = {"passed": all(item["passed"] for item in checks), "checks": checks, "summary": {"total": len(checks), "passed": sum(item["passed"] for item in checks), "failed": sum(not item["passed"] for item in checks)}, "gui_exercised": bool(App.GuiUp)} report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") return report if __name__ == "__main__": report = run(sys.argv[1] if len(sys.argv) > 1 else ROOT / "artifacts/drawing-bom-headless/kernel") print(json.dumps(report["summary"])) raise SystemExit(0 if report["passed"] else 1)