"""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)