669 lines
33 KiB
Python
669 lines
33 KiB
Python
"""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
|