307 lines
16 KiB
Python
307 lines
16 KiB
Python
"""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)
|