Files
Qinventor/tests/gui_suite.py
T

307 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Validation in the delivered CAD process; writes evidence before quitting."""
import json
import os
import traceback
import hashlib
import re
from datetime import datetime, timezone
import FreeCAD as App
import FreeCADGui as Gui
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
def run(root):
output = os.path.join(root, 'artifacts', 'validation')
os.makedirs(output, exist_ok=True)
report_path = os.path.join(root, 'artifacts', 'validation.json')
report = {'status': 'running', 'version': App.Version(),
'started': datetime.now(timezone.utc).isoformat(), 'checks': [],
'passed': 0, 'failed': 0}
def source_hashes():
entries = []
for directory in ('src', 'tests', 'scripts'):
for folder, subdirs, filenames in os.walk(os.path.join(root, directory)):
subdirs[:] = [d for d in subdirs if d not in ('__pycache__', 'bin', 'obj')]
for filename in sorted(filenames):
if filename.endswith(('.pyc', '.FCBak')):
continue
path = os.path.join(folder, filename)
with open(path, 'rb') as f:
digest = hashlib.sha256(f.read()).hexdigest()
entries.append({'path': os.path.relpath(path, root), 'sha256': digest})
return sorted(entries, key=lambda e: e['path'])
report['source_sha256'] = source_hashes()
def write():
with open(report_path, 'w', encoding='utf-8') as f:
json.dump(report, f, ensure_ascii=False, indent=2)
def check(name, fn):
try:
result = fn()
report['checks'].append({'name': name, 'passed': True, 'result': result})
report['passed'] += 1
except Exception:
report['checks'].append({'name': name, 'passed': False, 'error': traceback.format_exc()})
report['failed'] += 1
write()
def suite(name):
module = __import__(name)
result = module.run(output)
failures = [c for c in result.get('checks', []) if not c.get('passed', False)]
# Suites can provide either status or a direct failed count.
if result.get('passed') is False or result.get('failed', 0) or result.get('status') in ('failed', 'error') or failures:
raise AssertionError(json.dumps(result, ensure_ascii=False, default=str))
return result
def commands():
import qi_commands
custom = qi_commands.register_commands()
native = set(Gui.listCommands())
required = ['Sketcher_Dimension', 'Sketcher_CreatePolyline', 'Sketcher_CreateRectangle',
'Sketcher_CreateCircle', 'Sketcher_Projection', 'Sketcher_LeaveSketch', 'PartDesign_Pad',
'PartDesign_Pocket', 'Assembly_CreateJointFixed', 'Assembly_CreateJointRevolute',
'Assembly_SolveAssembly', 'TechDraw_View', 'TechDraw_Dimension']
missing = [x for x in custom + required if x not in native]
assert not missing, 'Missing registered tools: ' + str(missing)
details = {'custom': custom, 'native_count': len(native)}
if hasattr(Gui, 'Command'):
details['command_api'] = dir(Gui.Command)
try:
details['native_object_api'] = dir(Gui.Command.get('Sketcher_Dimension'))
except Exception as exc:
details['api_probe_error'] = str(exc)
return details
def ribbon():
import qi_ui
widget = qi_ui.show_ribbon()
main = Gui.getMainWindow()
main.resize(1440, 940)
# A hidden launcher window can report QWidget.isVisible while the
# native surface is not exposed. CAD clicks and OpenGL QA need exposure.
main.hide()
main.showNormal()
main.raise_()
main.activateWindow()
QtWidgets.QApplication.processEvents()
assert QtTest.QTest.qWaitForWindowExposed(main.windowHandle(), 5000), 'Native CAD window was not exposed'
QtTest.QTest.qWait(250)
assert 'QInventor' in main.windowTitle()
tabs = [t for t in main.findChildren(QtWidgets.QTabWidget)
if any('Эскиз' in t.tabText(i) for i in range(t.count()))]
assert tabs, 'QInventor workflow ribbon tabs missing'
labels = [tabs[0].tabText(i) for i in range(tabs[0].count())]
for target in ('Эскиз', 'Сборка', 'Вид'):
assert any(target in x for x in labels), labels
main.grab().save(os.path.join(output, 'ribbon.png'))
return labels
def sketch_workflow():
import qi_model
doc = qi_model.new_part('Проверка эскиза')
sketch = qi_model.create_sketch_profile('rectangle', 40, 25, doc=doc)
Gui.activeDocument().setEdit(sketch.Name)
QtWidgets.QApplication.processEvents()
assert Gui.activeDocument().getInEdit() is not None
sketch.ViewObject.Autoconstraints = True
sketch.ViewObject.AvoidRedundant = True
Gui.runCommand('QI_Dimension', 0)
QtWidgets.QApplication.processEvents()
# Esc cancels an active drawing command while preserving sketch edit.
try:
try:
from PySide6 import QtTest
except ImportError:
from PySide2 import QtTest
QtTest.QTest.keyClick(Gui.getMainWindow(), QtCore.Qt.Key_Escape)
except ImportError as exc:
raise AssertionError('Qt event testing runtime unavailable') from exc
assert Gui.activeDocument().getInEdit() is not None
Gui.getMainWindow().grab().save(os.path.join(output, 'sketch.png'))
Gui.runCommand('QI_FinishSketch', 0)
QtWidgets.QApplication.processEvents()
assert Gui.activeDocument().getInEdit() is None, 'Finish Sketch did not leave editor'
feature = qi_model.pad(sketch, 12)
assert feature.Shape.isValid() and abs(feature.Shape.Volume - 12000) < 1e-5
Gui.activeDocument().activeView().viewAxonometric()
Gui.activeDocument().activeView().fitAll()
Gui.activeDocument().activeView().redraw()
QtTest.QTest.qWait(350)
Gui.getMainWindow().grab().save(os.path.join(output, 'part.png'))
return {'sketch': sketch.Name, 'volume_mm3': feature.Shape.Volume}
def cancel_sketch():
import qi_model, qi_commands, qi_ui
import Part
doc = qi_model.new_part('Отмена эскиза')
sketch = qi_model.create_sketch_profile('rectangle', 30, 20, doc=doc)
original_count = sketch.GeometryCount
Gui.activeDocument().setEdit(sketch.Name)
QtWidgets.QApplication.processEvents()
assert qi_commands._editing() == sketch
sketch.addGeometry(Part.LineSegment(App.Vector(50, 0, 0), App.Vector(50, 20, 0)), True)
doc.recompute()
assert sketch.GeometryCount == original_count + 1
Gui.runCommand('QI_CancelSketch', 0)
QtWidgets.QApplication.processEvents()
assert Gui.activeDocument().getInEdit() is None
assert sketch.GeometryCount == original_count, 'Cancel did not restore original sketch'
chooser = qi_ui.choose_plane
qi_ui.choose_plane = lambda: 'XY'
try:
Gui.Selection.clearSelection()
Gui.runCommand('QI_NewSketch', 0)
new_sketch = qi_commands._editing()
assert new_sketch is not None
new_name = new_sketch.Name
new_sketch.addGeometry(Part.LineSegment(App.Vector(), App.Vector(10, 0, 0)), False)
Gui.runCommand('QI_CancelSketch', 0)
assert doc.getObject(new_name) is None, 'Cancelled new sketch was retained'
finally:
qi_ui.choose_plane = chooser
return {'existing_restored': True, 'new_removed': True}
def drawing():
import qi_model
doc = qi_model.make_demo('bracket')
page = qi_model.create_drawing(doc)
pdf = os.path.join(output, 'drawing.pdf')
svg = os.path.join(output, 'drawing.svg')
qi_model.export_drawing(page, pdf)
qi_model.export_drawing(page, svg)
assert os.path.getsize(pdf) > 1000 and os.path.getsize(svg) > 1000
return {'page': page.Name, 'pdf_bytes': os.path.getsize(pdf), 'views': len(page.Views)}
def localization():
import qi_model, qi_ui, qi_localization
qi_localization.install()
assert QtWidgets.QApplication.translate('App::Property', 'Length') == 'Длина'
assert qi_localization.translate_source('Constraint1') == 'Ограничение 1'
doc = qi_model.new_part('Проверка локализации')
body = qi_model.create_box(40, 25, 12, doc)
Gui.Selection.clearSelection()
Gui.Selection.addSelection(body)
# Native geometry errors can include English explanations and Russian
# filenames. Exercise the actual Apply button and its user message.
qi_ui.show_dimensions()
dialog = qi_ui._dimensions
original_set_dimensions = qi_model.set_dimensions
before_error = (body.Shape.Volume, doc.UndoCount, doc.RedoCount)
def native_error(*args, **kwargs):
raise RuntimeError('Failed to assign datum: C:\\Модели\\Деталь.FCStd')
try:
qi_model.set_dimensions = native_error
button_box = dialog.findChild(QtWidgets.QDialogButtonBox)
QtTest.QTest.mouseClick(button_box.button(QtWidgets.QDialogButtonBox.Apply), QtCore.Qt.LeftButton)
error_labels = [label.text() for label in dialog.findChildren(QtWidgets.QLabel)
if label.text().startswith('Не удалось')]
assert error_labels and all(not re.search(r'[A-Za-z]{3}', text) for text in error_labels), error_labels
assert (body.Shape.Volume, doc.UndoCount, doc.RedoCount) == before_error
finally:
qi_model.set_dimensions = original_set_dimensions
dialog.close()
# A non-modal parameter editor can outlive its native model. Its next
# Apply must explain the stale object in Russian, without crashing.
qi_ui.show_dimensions()
stale_dialog = qi_ui._dimensions
closed_name = doc.Name
App.closeDocument(closed_name)
try:
button_box = stale_dialog.findChild(QtWidgets.QDialogButtonBox)
QtTest.QTest.mouseClick(button_box.button(QtWidgets.QDialogButtonBox.Apply), QtCore.Qt.LeftButton)
error_labels = [label.text() for label in stale_dialog.findChildren(QtWidgets.QLabel)
if label.text().startswith('Не удалось')]
assert error_labels and all(not re.search(r'[A-Za-z]{3}', text) for text in error_labels), error_labels
assert closed_name not in App.listDocuments()
finally:
stale_dialog.close()
doc = qi_model.new_part('Проверка локализации')
body = qi_model.create_box(40, 25, 12, doc)
Gui.Selection.clearSelection()
Gui.Selection.addSelection(body)
QtTest.QTest.qWait(850)
Gui.activeDocument().activeView().viewAxonometric()
Gui.activeDocument().activeView().fitAll()
Gui.activeDocument().activeView().redraw()
QtTest.QTest.qWait(100)
qi_localization.localize_surface()
menu_titles = [action.text().replace('&', '') for action in Gui.getMainWindow().menuBar().actions()
if action.isVisible() and not action.isSeparator()]
english_menus = [title for title in menu_titles if re.search(r'[A-Za-z]{2}', title)
and not re.search(r'[А-Яа-яЁё]', title) and title not in ('QInventor', 'FreeCAD')]
assert not english_menus, 'Untranslated top-level menus: ' + str(english_menus)
candidates = qi_localization.untranslated_surface()
with open(os.path.join(output, 'localization.json'), 'w', encoding='utf-8') as f:
json.dump(candidates, f, ensure_ascii=False, indent=2)
Gui.getMainWindow().grab().save(os.path.join(output, 'localization.png'))
assert not candidates, 'Untranslated visible UI: ' + str(candidates)
return {'supplemental_sources': len(qi_localization.TRANSLATIONS), 'visible_untranslated': 0,
'native_menu_titles': menu_titles, 'parameter_backend_error_russian': True,
'deleted_parameter_object_error_russian': True}
def examples():
import qi_model
for kind in ('bracket', 'assembly'):
doc = qi_model.make_demo(kind)
qi_model.save_project(doc, os.path.join(root, 'examples', kind + '.FCStd'))
qi_model.export_model(doc, os.path.join(root, 'examples', kind + '.step'))
Gui.activeDocument().activeView().viewAxonometric()
Gui.activeDocument().activeView().fitAll()
QtWidgets.QApplication.processEvents()
QtTest.QTest.qWait(200)
Gui.activeDocument().activeView().fitAll()
Gui.activeDocument().activeView().redraw()
QtTest.QTest.qWait(100)
Gui.getMainWindow().grab().save(os.path.join(output, kind + '.png'))
return ['bracket.FCStd', 'bracket.step', 'assembly.FCStd', 'assembly.step']
write()
check('native_and_custom_commands', commands)
check('russian_workflow_ribbon', ribbon)
check('native_mouse_keyboard_sketch_ribbon_suite', lambda: suite('ribbon_suite'))
check('associative_sketch_projection_suite', lambda: suite('projection_suite'))
check('associative_sketch_offset_suite', lambda: suite('sketch_offset_suite'))
check('associative_sketch_patterns_suite', lambda: suite('sketch_patterns_suite'))
check('native_sketch_operations_dialog_suite', lambda: suite('sketch_operations_gui_suite'))
check('stable_sketch_workspace_and_dimension_workflow', lambda: suite('sketch_workspace_suite'))
check('parametric_solid_kernel_suite', lambda: suite('kernel_suite'))
check('native_project_russian_label_suite', lambda: suite('io_suite'))
check('unified_extrude_suite', lambda: suite('extrude_suite'))
check('typed_parameter_editor_suite', lambda: suite('parameters_suite'))
check('document_parameter_table_kernel_suite', lambda: suite('parameter_table_suite'))
check('native_parameter_preflight_fidelity_suite', lambda: suite('parameter_preflight_suite'))
check('document_parameter_table_gui_suite', lambda: suite('parameter_table_gui_suite'))
check('assembly_joint_validation_suite', lambda: suite('assembly_suite'))
check('nested_assembly_bom_kernel_suite', lambda: suite('bom_suite'))
check('nested_assembly_bom_gui_suite', lambda: suite('bom_gui_suite'))
check('drawing_bom_kernel_suite', lambda: suite('drawing_bom_suite'))
check('drawing_bom_gui_suite', lambda: suite('drawing_bom_gui_suite'))
check('assembly_drawing_command_workflow_suite', lambda: suite('command_workflows_suite'))
check('sketch_dimension_finish_pad_workflow', sketch_workflow)
check('cancel_new_and_existing_sketch', cancel_sketch)
check('drawing_pdf_svg_export', drawing)
check('russian_native_and_custom_surfaces', localization)
check('shipped_examples', examples)
check('source_snapshot_consistent', lambda: (
True if report['source_sha256'] == source_hashes() else
(_ for _ in ()).throw(AssertionError('Source files changed during validation'))))
report['status'] = 'failed' if report['failed'] else 'passed'
report['finished'] = datetime.now(timezone.utc).isoformat()
write()
# Only test-created documents exist in this isolated profile/process.
for doc in list(App.listDocuments().values()):
App.closeDocument(doc.Name)
Gui.Control.closeDialog()
QtCore.QTimer.singleShot(200, Gui.getMainWindow().close)