Files

669 lines
33 KiB
Python
Raw Permalink 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.
"""Real Qt insertion, nested material-aware BOM, CSV and FCStd workflows."""
import csv
import hashlib
import io
import json
import math
from pathlib import Path
import re
import sys
import time
import traceback
import uuid
import zipfile
import FreeCAD as App
import FreeCADGui as Gui
import Part
from PySide import QtCore, QtGui
try:
from PySide import QtWidgets
except ImportError:
QtWidgets = QtGui
try:
from PySide6 import QtTest
except ImportError:
from PySide2 import QtTest
SOURCE = Path(__file__).resolve().parents[1] / "src" / "QInventor"
if str(SOURCE) not in sys.path:
sys.path.insert(0, str(SOURCE))
def _near(actual, expected, rel_tol=1e-9):
assert math.isclose(float(actual), float(expected), rel_tol=rel_tol, abs_tol=1e-11), (actual, expected)
def _settle(ms=80):
QtWidgets.QApplication.processEvents()
QtTest.QTest.qWait(ms)
def _click(widget):
QtTest.QTest.mouseClick(widget, QtCore.Qt.LeftButton)
_settle()
def _combo(combo, data):
index = combo.findData(data)
assert index >= 0, (combo.objectName(), data)
combo.setFocus()
QtTest.QTest.keyClick(combo, QtCore.Qt.Key_Home)
for _ in range(index):
QtTest.QTest.keyClick(combo, QtCore.Qt.Key_Down)
_settle()
assert combo.currentData() == data
def _items(tree):
def descend(item):
yield item
for index in range(item.childCount()):
yield from descend(item.child(index))
for index in range(tree.topLevelItemCount()):
yield from descend(tree.topLevelItem(index))
def _display_rows(dialog):
return [dict(item.data(0, QtCore.Qt.UserRole)) for item in _items(dialog.tree)]
def _document_state(doc):
# Compare all native payloads, excluding ZIP creation timestamps only.
contents = []
for obj in doc.Objects:
with zipfile.ZipFile(io.BytesIO(bytes(obj.dumpContent()))) as archive:
members = tuple((member.filename, archive.read(member)) for member in archive.infolist())
contents.append((obj.Name, members, tuple(obj.State)))
return tuple(contents), doc.UndoCount, doc.RedoCount, Gui.getDocument(doc.Name).Modified
def run(output_dir):
import qi_bom
import qi_commands as commands
import qi_model as model
output = Path(output_dir).absolute()
output.mkdir(parents=True, exist_ok=True)
original_docs = set(App.listDocuments())
active_name = App.ActiveDocument.Name if App.ActiveDocument else None
dialogs = []
fixture = None
report_path = output / "bom-gui-report.json"
report = {"status": "running", "passed": 0, "failed": 0, "checks": [],
"input_delivery": "Qt native Assembly insertion list, BOM controls, native file and property dialogs",
"fixture_boundary": "native Assembly::AssemblyLink/App::Link instances of saved FCStd sources",
"os_pointer_access": False}
main = Gui.getMainWindow()
main.resize(1440, 940)
main.hide()
main.showNormal()
main.raise_()
main.activateWindow()
assert QtTest.QTest.qWaitForWindowExposed(main.windowHandle(), 5000)
commands.register_commands()
def write():
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
def check(name, action):
report["running_case"] = name
write()
try:
report["checks"].append({"name": name, "passed": True, "evidence": action()})
report["passed"] += 1
except Exception:
report["checks"].append({"name": name, "passed": False, "error": traceback.format_exc()})
report["failed"] += 1
write()
def capture(dialog, name):
_settle(120)
path = output / (name + ".png")
assert dialog.isVisible() and dialog.grab().save(str(path))
return str(path)
def task_buttons():
boxes = [box for box in main.findChildren(QtWidgets.QDialogButtonBox)
if box.isVisible() and box.button(QtWidgets.QDialogButtonBox.Ok)]
assert boxes, "Native task dialog has no actual OK/Cancel buttons"
return boxes[-1]
def object_reference(obj):
if obj is None:
return None
if not hasattr(obj, "Document") and hasattr(obj, "Object"):
obj = obj.Object
if not hasattr(obj, "Document"):
return {"native_class": type(obj).__name__, "name": str(getattr(obj, "Name", "")),
"representation": repr(obj)}
return {"document": obj.Document.Name, "name": obj.Name, "type": obj.TypeId, "label": obj.Label}
def native_context():
import UtilsAssembly
gui_doc = Gui.ActiveDocument
view = gui_doc.ActiveView if gui_doc else None
return {"app_document": App.ActiveDocument.Name if App.ActiveDocument else None,
"gui_document": gui_doc.Document.Name if gui_doc else None,
"in_edit": {name: object_reference(Gui.getDocument(name).getInEdit())
for name in App.listDocuments()},
"selection": [object_reference(obj) for obj in Gui.Selection.getSelection()],
"active_assembly": object_reference(UtilsAssembly.activeAssembly()),
"view_assembly": object_reference(view.getActiveObject("assembly")) if view else None,
"view_part": object_reference(view.getActiveObject("part")) if view else None,
"assembly_groups": {name + "#" + obj.Name: [object_reference(member) for member in obj.Group]
for name, document in App.listDocuments().items() for obj in document.Objects
if obj.TypeId == "Assembly::AssemblyObject"}}
def make_fixture():
nonlocal fixture
if fixture is not None:
return fixture
attempt = {"number": len(report.setdefault("fixture_attempts", [])) + 1,
"initial_docs": list(App.listDocuments()), "clicks": []}
fixture_directory = output / ("bom-gui-fixture-" + uuid.uuid4().hex)
fixture_directory.mkdir()
attempt["fixture_directory"] = str(fixture_directory)
report["fixture_attempts"].append(attempt)
write()
parts = model.new_part("Детали спецификации")
housing = model.create_box(10, 10, 10, parts)
housing.Label = "Корпус"
housing.QIPartNumber = "GUI-HOUSING"
model.set_material(housing, "Алюминий")
bolt = model.create_cylinder(1, 10, parts)
bolt.Label = "Болт"
bolt.QIPartNumber = "GUI-BOLT"
model.set_material(bolt, "Сталь")
parts_path = Path(model.save_project(parts, fixture_directory / "bom-gui-parts.FCStd"))
subdoc = model.new_assembly("Подсборка спецификации")
sub = subdoc.getObject("Assembly")
sub.Label = "Подсборка"
sub_path = Path(model.save_project(subdoc, fixture_directory / "bom-gui-subassembly.FCStd"))
base = model.add_component(sub, housing)
model.ground_component(sub, base)
model.add_component(sub, bolt, App.Placement(App.Vector(15, 0, 0), App.Rotation()))
model.add_component(sub, bolt, App.Placement(App.Vector(20, 0, 0), App.Rotation()))
subdoc.recompute()
assert sub.solve(False) == 0
model.save_project(subdoc, sub_path)
doc = model.new_assembly("Сборка спецификации")
assembly = doc.getObject("Assembly")
root_path = Path(model.save_project(doc, fixture_directory / "bom-gui-assembly.FCStd"))
construction = doc.addObject("App::Part", "Construction")
construction.Label = "Вспомогательные построения"
construction_sketch = construction.newObject("Sketcher::SketchObject", "ConstructionSketch")
construction_sketch.addGeometry(Part.LineSegment(App.Vector(0, 0, 0), App.Vector(5, 0, 0)), True)
# A valid but unused prototype must never become an extra physical root.
definitions = doc.addObject("App::Part", "Definitions")
definitions.Label = "Определения компонентов"
prototype = doc.addObject("Assembly::AssemblyObject", "PrototypeAssembly")
prototype.Type = "Assembly"
prototype.Label = "Определение подсборки"
prototype.newObject("Assembly::JointGroup", "Joints")
definitions.addObject(prototype)
model.add_component(prototype, housing)
model.add_component(prototype, bolt)
definitions.Visibility = False
doc.recompute()
gui_doc = Gui.getDocument(doc.Name)
attempt["before_set_edit"] = native_context()
attempt["expected_assembly"] = object_reference(assembly)
write()
assert gui_doc.setEdit(assembly.Name)
attempt["after_set_edit"] = native_context()
write()
import Preferences
preferences = Preferences.preferences()
old_ground = preferences.GetInt("GroundFirstPart", 0)
preferences.SetInt("GroundFirstPart", 1)
try:
Gui.runCommand("Assembly_InsertLink", 0)
_settle(200)
attempt["after_native_command"] = native_context()
write()
trees = [tree for tree in main.findChildren(QtWidgets.QTreeWidget, "partList") if tree.isVisible()]
assert len(trees) == 1, "Native Assembly insertion task was not shown"
tree = trees[0]
tree.expandAll()
for source in (sub, sub, bolt):
candidates = []
for item in _items(tree):
target = item.data(0, QtCore.Qt.UserRole)
if target is not None and getattr(target, "Name", None) == source.Name \
and getattr(getattr(target, "Document", None), "Name", None) == source.Document.Name:
candidates.append(item)
assert len(candidates) == 1, "Native insertion source missing: " + source.Label
item = candidates[0]
tree.scrollToItem(item)
_settle()
rectangle = tree.visualItemRect(item)
assert rectangle.isValid() and not rectangle.isEmpty()
clicked_item = tree.itemAt(rectangle.center())
event = {"requested_source": object_reference(source),
"item_source": object_reference(item.data(0, QtCore.Qt.UserRole)),
"item_at_click": object_reference(clicked_item.data(0, QtCore.Qt.UserRole)) if clicked_item else None,
"rectangle": [rectangle.x(), rectangle.y(), rectangle.width(), rectangle.height()],
"viewport": [tree.viewport().width(), tree.viewport().height()],
"before": native_context()}
attempt["clicks"].append(event)
write()
QtTest.QTest.mouseClick(tree.viewport(), QtCore.Qt.LeftButton, QtCore.Qt.NoModifier, rectangle.center())
_settle(150)
event["after"] = native_context()
write()
attempt["before_accept"] = native_context()
write()
_click(task_buttons().button(QtWidgets.QDialogButtonBox.Ok))
attempt["after_accept"] = native_context()
write()
assert not Gui.Control.activeDialog()
finally:
preferences.SetInt("GroundFirstPart", old_ground)
if gui_doc.getInEdit():
gui_doc.resetEdit()
doc.recompute()
links = [member for member in assembly.Group
if member.isDerivedFrom("App::Link") or member.isDerivedFrom("Assembly::AssemblyLink")]
attempt["actual_links"] = [object_reference(link) for link in links]
attempt["before_count_assert"] = native_context()
write()
assert len(links) == 3
assert sum(link.isDerivedFrom("Assembly::AssemblyLink") for link in links) == 2
assert assembly.solve(False) == 0
model.save_project(doc, root_path)
fixture = {"doc": doc, "assembly": assembly, "parts": parts, "housing": housing, "bolt": bolt,
"subdoc": subdoc, "sub": sub, "parts_path": parts_path, "sub_path": sub_path,
"root_path": root_path, "prototype": prototype, "links": links,
"construction": construction}
return fixture
def open_bom(doc):
App.setActiveDocument(doc.Name)
Gui.Selection.clearSelection()
assert not Gui.Selection.getSelection()
Gui.runCommand("QI_BOM", 0)
_settle(120)
candidates = [dialog for dialog in main.findChildren(QtWidgets.QDialog, "QI_BOMTable")
if dialog.isVisible() and getattr(dialog, "document", None) == doc]
assert len(candidates) == 1, "QI_BOM did not open a document table without selection"
dialog = candidates[0]
dialogs.append(dialog)
assert not dialog.isModal() and dialog.export_button.isEnabled(), dialog.status.text()
return dialog
def flat_quantities(dialog, expected_bolts=5, expected_housings=2):
rows = _display_rows(dialog)
assert len(rows) == 2 and all(not row["is_assembly"] for row in rows)
by_number = {row["part_number"]: row for row in rows}
assert set(by_number) == {"GUI-BOLT", "GUI-HOUSING"}
_near(by_number["GUI-BOLT"]["quantity"], expected_bolts)
_near(by_number["GUI-HOUSING"]["quantity"], expected_housings)
assert by_number["GUI-BOLT"]["material"] == "Сталь"
assert by_number["GUI-HOUSING"]["material"] == "Алюминий"
assert len({row["source_id"] for row in rows}) == 2
_near(sum(row["mass_kg"] for row in rows), dialog.summary_data["mass_kg"])
return by_number
def export_with_native_dialog(dialog, path):
original_attribute = QtWidgets.QApplication.testAttribute(QtCore.Qt.AA_DontUseNativeDialogs)
QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_DontUseNativeDialogs, True)
evidence = {"dialog_seen": False, "save_clicked": False, "errors": []}
timer = QtCore.QTimer()
started = time.monotonic()
def choose():
try:
for widget in QtWidgets.QApplication.topLevelWidgets():
if isinstance(widget, QtWidgets.QMessageBox) and widget.isVisible():
evidence["errors"].append(widget.windowTitle() + ": " + widget.text())
timer.stop()
widget.reject()
return
if not isinstance(widget, QtWidgets.QFileDialog) or not widget.isVisible():
continue
evidence["dialog_seen"] = True
widget.setOption(QtWidgets.QFileDialog.DontConfirmOverwrite, True)
widget.setDirectory(str(path.parent))
editor = widget.findChild(QtWidgets.QLineEdit, "fileNameEdit")
assert editor is not None
editor.setFocus()
editor.selectAll()
QtTest.QTest.keyClick(editor, QtCore.Qt.Key_Backspace)
QtTest.QTest.keyClicks(editor, path.name)
boxes = widget.findChildren(QtWidgets.QDialogButtonBox)
button = next((box.button(QtWidgets.QDialogButtonBox.Save) for box in boxes
if box.button(QtWidgets.QDialogButtonBox.Save)), None)
assert button is not None
timer.stop()
evidence["save_clicked"] = True
QtTest.QTest.mouseClick(button, QtCore.Qt.LeftButton)
return
if time.monotonic() - started > 10:
timer.stop()
evidence["errors"].append("Save dialog did not appear")
modal = QtWidgets.QApplication.activeModalWidget()
if isinstance(modal, QtWidgets.QDialog):
evidence["errors"].append("Unexpected modal: " + modal.windowTitle())
modal.reject()
except Exception:
evidence["errors"].append(traceback.format_exc())
timer.stop()
modal = QtWidgets.QApplication.activeModalWidget()
if isinstance(modal, QtWidgets.QDialog):
modal.reject()
timer.timeout.connect(choose)
timer.start(30)
try:
_click(dialog.export_button)
finally:
timer.stop()
timer.deleteLater()
QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_DontUseNativeDialogs, original_attribute)
assert not evidence["errors"], evidence
assert evidence["dialog_seen"] and evidence["save_clicked"], evidence
assert "сохранена" in dialog.status.text() and path.is_file(), dialog.status.text()
with path.open(encoding="utf-8-sig", newline="") as stream:
rows = list(csv.DictReader(stream, delimiter=";"))
assert len(rows) == len(dialog.rows)
return rows
def native_insert_and_views():
data = make_fixture()
doc, assembly = data["doc"], data["assembly"]
material_rejections = []
for target in (assembly, next(link for link in data["links"]
if link.isDerivedFrom("Assembly::AssemblyLink"))):
App.setActiveDocument(doc.Name)
Gui.Selection.clearSelection()
Gui.Selection.addSelection(target)
_settle()
before = {name: _document_state(document) for name, document in App.listDocuments().items()}
input_dialogs = []
timer = QtCore.QTimer()
def reject_unexpected_material_dialog():
for widget in QtWidgets.QApplication.topLevelWidgets():
if isinstance(widget, QtWidgets.QInputDialog) and widget.isVisible():
input_dialogs.append(widget.windowTitle())
widget.reject()
timer.timeout.connect(reject_unexpected_material_dialog)
timer.start(30)
rejection = None
try:
try:
commands._material()
except ValueError as error:
rejection = str(error)
finally:
timer.stop()
timer.deleteLater()
assert rejection and "Выберите конечную деталь" in rejection, rejection
assert not input_dialogs, input_dialogs
after = {name: _document_state(document) for name, document in App.listDocuments().items()}
assert after == before, "Rejected assembly material changed native metadata or history"
material_rejections.append({"type": target.TypeId, "message": rejection})
dialog = open_bom(doc)
assert dialog.mode_selector.currentData() == "flat"
assert dialog.root_selector.currentData() == ""
assert dialog.root_selector.findData(data["prototype"].Name) < 0
assert qi_bom.bom_rows(doc, root=data["construction"]) == []
assert qi_bom.bom_summary(doc, root=data["construction"])["components"] == 0
by_number = flat_quantities(dialog)
expected_mass = 2 * data["housing"].Shape.Volume * model.MATERIALS["Алюминий"] * 1e-9 \
+ 5 * data["bolt"].Shape.Volume * model.MATERIALS["Сталь"] * 1e-9
_near(dialog.summary_data["mass_kg"], expected_mass)
assert dialog.summary_data["components"] == 7
assert set(dialog.summary_data["materials"]) == {"Сталь", "Алюминий"}
assert "Масса:" in dialog.summary.text() and "," in dialog.summary.text()
hidden_link = data["links"][-1]
hidden_link.Visibility = False
_click(dialog.refresh_button)
flat_quantities(dialog)
_near(dialog.summary_data["mass_kg"], expected_mass)
hidden_link.Visibility = True
flat_screenshot = capture(dialog, "bom-gui-flat")
_combo(dialog.mode_selector, "structured")
rows = _display_rows(dialog)
assert any(row["is_assembly"] for row in rows)
assert any(row["level"] > 0 and row["parent_position"] for row in rows)
for item in _items(dialog.tree):
row = item.data(0, QtCore.Qt.UserRole)
assert item.text(0) == row["position"]
assert not (item.flags() & QtCore.Qt.ItemIsEditable)
if row["parent_position"]:
assert item.parent() is not None and item.parent().text(0) == row["parent_position"]
_near(dialog.summary_data["mass_kg"], expected_mass)
leaf_bolts = [row for row in rows if not row["is_assembly"] and row["part_number"] == "GUI-BOLT"]
_near(sum(row["quantity_total"] for row in leaf_bolts), 5)
assert any(row["quantity"] == 2 and row["quantity_total"] == 4 for row in leaf_bolts)
structured_screenshot = capture(dialog, "bom-gui-structured")
_combo(dialog.root_selector, assembly.Name)
_near(dialog.summary_data["mass_kg"], expected_mass)
nested = next(link for link in data["links"] if link.isDerivedFrom("Assembly::AssemblyLink"))
assert dialog.root_selector.findData(nested.Name) >= 0
_combo(dialog.root_selector, nested.Name)
assert dialog.summary_data["components"] == 3
_combo(dialog.root_selector, assembly.Name)
dialog.close()
return {"native_inserted_subassemblies": 2, "native_inserted_direct_bolts": 1,
"flat_quantities": {number: row["quantity"] for number, row in by_number.items()},
"mixed_mass_kg": expected_mass, "prototype_excluded": True,
"construction_sketch_container_has_no_BOM_rows": True,
"assembly_material_rejected_before_chooser_without_mutation": material_rejections,
"physical_counts_ignore_visibility": True,
"structured_local_and_total_quantities": True, "selected_live_nested_instance": True,
"screenshots": [flat_screenshot, structured_screenshot]}
def properties_and_refresh_export_reopen():
data = make_fixture()
doc, assembly = data["doc"], data["assembly"]
App.setActiveDocument(doc.Name)
Gui.Selection.clearSelection()
properties = {}
properties_errors = []
timer = QtCore.QTimer()
started = time.monotonic()
def close_properties():
try:
for widget in QtWidgets.QApplication.topLevelWidgets():
if isinstance(widget, QtWidgets.QMessageBox) and widget.isVisible():
properties_errors.append(widget.windowTitle() + ": " + widget.text())
timer.stop()
widget.reject()
return
if isinstance(widget, QtWidgets.QDialog) and widget.isVisible() \
and widget.windowTitle().startswith("Физические свойства"):
table = widget.findChild(QtWidgets.QTableWidget)
if table is None:
continue
properties.update({table.item(row, 0).text(): table.item(row, 1).text()
for row in range(table.rowCount())})
box = widget.findChild(QtWidgets.QDialogButtonBox)
assert box is not None and box.button(QtWidgets.QDialogButtonBox.Close) is not None
timer.stop()
QtTest.QTest.mouseClick(box.button(QtWidgets.QDialogButtonBox.Close), QtCore.Qt.LeftButton)
return
if time.monotonic() - started > 10:
properties_errors.append("Physical properties dialog did not appear")
timer.stop()
modal = QtWidgets.QApplication.activeModalWidget()
if isinstance(modal, QtWidgets.QDialog):
properties_errors.append("Unexpected modal: " + modal.windowTitle())
modal.reject()
except Exception:
properties_errors.append(traceback.format_exc())
timer.stop()
modal = QtWidgets.QApplication.activeModalWidget()
if isinstance(modal, QtWidgets.QDialog):
modal.reject()
timer.timeout.connect(close_properties)
timer.start(30)
try:
Gui.runCommand("QI_Properties", 0)
finally:
timer.stop()
timer.deleteLater()
assert not properties_errors, properties_errors
assert properties and "Материалы деталей" in properties and "Средняя плотность, кг/м³" in properties
assert "Центр масс, мм" in properties and "Экземпляров деталей" in properties
assert not any("_" in label for label in properties), properties
assert properties["Экземпляров деталей"] == "7"
expected = qi_bom.bom_summary(doc, root=assembly)
_near(float(properties["Масса, кг"].replace(",", ".")), expected["mass_kg"], rel_tol=1e-5)
dialog = open_bom(doc)
_combo(dialog.root_selector, assembly.Name)
old_mass = dialog.summary_data["mass_kg"]
model.set_dimensions(data["housing"], {"QIHeight": 15})
data["parts"].recompute()
_click(dialog.refresh_button)
assert "обновлена" in dialog.status.text(), dialog.status.text()
assert dialog.summary_data["mass_kg"] > old_mass
flat_quantities(dialog)
# Export performs its own refresh after an additional native source edit.
model.set_dimensions(data["housing"], {"QIHeight": 20})
flat_path = output / "bom-gui-flat.csv"
flat_csv = export_with_native_dialog(dialog, flat_path)
csv_by_number = {row["Обозначение"]: row for row in flat_csv}
assert csv_by_number["GUI-BOLT"]["Количество"] == "5"
assert csv_by_number["GUI-HOUSING"]["Количество"] == "2"
expected_mass = qi_bom.bom_summary(doc, root=assembly)["mass_kg"]
_near(dialog.summary_data["mass_kg"], expected_mass)
_near(sum(float(row["Масса, кг"].replace(",", ".")) for row in flat_csv), expected_mass)
_combo(dialog.mode_selector, "structured")
structured_path = output / "bom-gui-structured.csv"
structured_csv = export_with_native_dialog(dialog, structured_path)
assert any(row["Родитель"] and int(row["Уровень"]) > 0 for row in structured_csv)
assert any(row["Обозначение"] == "GUI-BOLT" and row["Количество"] == "2" and row["Всего"] == "4"
for row in structured_csv)
screenshot = capture(dialog, "bom-gui-updated-and-exported")
dialog.close()
model.save_project(data["parts"], data["parts_path"])
model.save_project(data["subdoc"], data["sub_path"])
model.save_project(doc, data["root_path"])
for name in (doc.Name, data["subdoc"].Name, data["parts"].Name):
gui_doc = Gui.getDocument(name)
if gui_doc.getInEdit():
gui_doc.resetEdit()
App.closeDocument(name)
restored = App.openDocument(str(data["root_path"]))
restored_dialog = open_bom(restored)
flat_quantities(restored_dialog)
_near(restored_dialog.summary_data["mass_kg"], expected_mass)
assert all(source_id.split("#", 1)[0] in App.listDocuments()
for source_id in (row["source_id"] for row in restored_dialog.rows))
restored_screenshot = capture(restored_dialog, "bom-gui-reopened")
restored_dialog.close()
data["restored"] = restored
return {"mass_properties_russian_labels": properties, "source_edit_refresh": True,
"export_refreshes_current_model": True, "native_save_dialog_keyboard_and_button": True,
"flat_csv": str(flat_path), "structured_csv": str(structured_path),
"FCStd_external_sources_reopened": True, "mass_kg_after_reopen": expected_mass,
"screenshots": [screenshot, restored_screenshot]}
def failed_source_closed_document_and_native_task():
data = make_fixture()
doc = data.get("restored") or data["doc"]
dialog = open_bom(doc)
rows = flat_quantities(dialog)
housing_id = rows["GUI-HOUSING"]["source_id"]
source_doc_name, source_name = housing_id.split("#", 1)
source_doc = App.getDocument(source_doc_name)
housing = source_doc.getObject(source_name)
pad = housing.Tip
profile = pad.Profile
profile_object = profile[0] if isinstance(profile, (tuple, list)) else profile
csv_path = output / "bom-gui-flat.csv"
old_hash = hashlib.sha256(csv_path.read_bytes()).hexdigest()
source_doc.openTransaction("Ошибка исходной операции перед экспортом")
pad.Profile = None
source_doc.recompute()
source_doc.commitTransaction()
assert "Invalid" in pad.State and not pad.Shape.isNull()
unexpected_file_dialog = []
timer = QtCore.QTimer()
def reject_unexpected_save():
for widget in QtWidgets.QApplication.topLevelWidgets():
if isinstance(widget, QtWidgets.QFileDialog) and widget.isVisible():
unexpected_file_dialog.append(widget.windowTitle())
widget.reject()
timer.timeout.connect(reject_unexpected_save)
timer.start(30)
try:
_click(dialog.export_button)
finally:
timer.stop()
timer.deleteLater()
assert not unexpected_file_dialog and not dialog.export_button.isEnabled()
assert "не обновлена" in dialog.status.text() and re.search(r"[А-Яа-я]", dialog.status.text())
assert hashlib.sha256(csv_path.read_bytes()).hexdigest() == old_hash
source_doc.openTransaction("Восстановить исходную операцию")
pad.Profile = profile
source_doc.recompute()
source_doc.commitTransaction()
_click(dialog.refresh_button)
assert dialog.export_button.isEnabled(), dialog.status.text()
App.setActiveDocument(source_doc.Name)
source_gui = Gui.getDocument(source_doc.Name)
# FCStd external dependencies load without a viewport. Open the native
# view before entering the real sketch task, as an engineer does.
if not source_gui.mdiViewsOfType("Gui::View3DInventor"):
assert "Std_ViewCreate" in Gui.listCommands()
Gui.runCommand("Std_ViewCreate", 0)
_settle()
assert source_gui.mdiViewsOfType("Gui::View3DInventor")
assert source_gui.setEdit(profile_object.Name)
_settle()
_click(dialog.refresh_button)
assert not dialog.export_button.isEnabled() and "Завершите" in dialog.status.text()
source_gui.resetEdit()
App.setActiveDocument(doc.Name)
_click(dialog.refresh_button)
assert dialog.export_button.isEnabled(), dialog.status.text()
App.closeDocument(doc.Name)
_click(dialog.refresh_button)
assert dialog.isVisible() and "закрыт" in dialog.status.text() and not dialog.export_button.isEnabled()
dialog.close()
return {"invalid_cached_source_blocks_export": True, "previous_csv_unchanged": old_hash,
"native_task_blocks_refresh": True, "closed_document_refuses_refresh_and_export": True}
try:
for name, action in (("Native insertion, flat and structured quantities, materials and nested scope", native_insert_and_views),
("Russian mass properties, native source refresh, actual CSV dialogs and FCStd reopen", properties_and_refresh_export_reopen),
("Invalid cached source, native task and closed document protect BOM export", failed_source_closed_document_and_native_task)):
check(name, action)
finally:
for dialog in dialogs:
try:
dialog.close()
except RuntimeError:
pass
if Gui.Control.activeDialog():
boxes = [box for box in main.findChildren(QtWidgets.QDialogButtonBox)
if box.isVisible() and box.button(QtWidgets.QDialogButtonBox.Cancel)]
if boxes:
_click(boxes[-1].button(QtWidgets.QDialogButtonBox.Cancel))
else:
Gui.Control.closeDialog()
Gui.Selection.clearSelection()
for name in set(App.listDocuments()) - original_docs:
gui_doc = Gui.getDocument(name)
if gui_doc.getInEdit():
gui_doc.resetEdit()
_settle()
for name in set(App.listDocuments()) - original_docs:
App.closeDocument(name)
if active_name and active_name in App.listDocuments():
App.setActiveDocument(active_name)
report.pop("running_case", None)
report["status"] = "passed" if report["failed"] == 0 else "failed"
report["count"] = len(report["checks"])
write()
return report