diff --git a/src/Mod/Assembly/AssemblyTests/TestCommandInsertLink.py b/src/Mod/Assembly/AssemblyTests/TestCommandInsertLink.py new file mode 100644 index 0000000000..d88df7593f --- /dev/null +++ b/src/Mod/Assembly/AssemblyTests/TestCommandInsertLink.py @@ -0,0 +1,193 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# /**************************************************************************** +# * +# Copyright (c) 2025 Weston Schmidt * +# * +# This file is part of FreeCAD. * +# * +# FreeCAD is free software: you can redistribute it and/or modify it * +# under the terms of the GNU Lesser General Public License as * +# published by the Free Software Foundation, either version 2.1 of the * +# License, or (at your option) any later version. * +# * +# FreeCAD is distributed in the hope that it will be useful, but * +# WITHOUT ANY WARRANTY; without even the implied warranty of * +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# Lesser General Public License for more details. * +# * +# You should have received a copy of the GNU Lesser General Public * +# License along with FreeCAD. If not, see * +# . * +# * +# ***************************************************************************/ + +""" +Unit tests for CommandInsertLink module. + +This module contains unit tests to verify the proper handling of null +LinkedObject references in the TaskAssemblyInsertLink.accept() method. +Tests ensure that invalid objects are gracefully skipped without causing +AttributeError crashes. +""" + +import unittest +from unittest.mock import patch, MagicMock + +import FreeCAD as App + +# Only import CommandInsertLink if GUI is available +if App.GuiUp: + import CommandInsertLink + + +def _msg(text, end="\n"): + """Write messages to the console including the line ending.""" + App.Console.PrintMessage(text + end) + + +@unittest.skipIf(not App.GuiUp, "GUI tests require FreeCAD GUI mode") +class TestCommandInsertLink(unittest.TestCase): + """Unit tests for CommandInsertLink module.""" + + @classmethod + def setUpClass(cls): + """setUpClass()... + This method is called upon instantiation of this test class. Add code and objects here + that are needed for the duration of the test() methods in this class. In other words, + set up the 'global' test environment here; use the `setUp()` method to set up a 'local' + test environment. + This method does not have access to the class `self` reference, but it + is able to call static methods within this same class. + """ + + @classmethod + def tearDownClass(cls): + """tearDownClass()... + This method is called prior to destruction of this test class. Add code and objects here + that cleanup the test environment after the test() methods in this class have been executed. + This method does not have access to the class `self` reference. This method + is able to call static methods within this same class. + """ + + def setUp(self): + """setUp()... + This method is called prior to each `test()` method. Add code and objects here + that are needed for multiple `test()` methods. + """ + doc_name = self.__class__.__name__ + if App.ActiveDocument: + if App.ActiveDocument.Name != doc_name: + App.newDocument(doc_name) + else: + App.newDocument(doc_name) + App.setActiveDocument(doc_name) + self.doc = App.ActiveDocument + + self.assembly = App.ActiveDocument.addObject("Assembly::AssemblyObject", "Assembly") + + _msg(f" Temporary document '{self.doc.Name}'") + + def tearDown(self): + """tearDown()... + This method is called after each test() method. Add cleanup instructions here. + Such cleanup instructions will likely undo those in the setUp() method. + """ + App.closeDocument(self.doc.Name) + + @patch("FreeCADGui.PySideUic.loadUi") + @patch("CommandInsertLink.TaskAssemblyInsertLink.adjustTreeWidgetSize") + def test_mixed_valid_and_invalid_objects(self, _mock_adjustTreeSize, _mock_loadUi): + """Test that accept() handles a mix of valid and invalid objects correctly.""" + operation = "Handle mixed valid/invalid objects" + _msg(f" Test '{operation}'") + + mock_view = MagicMock() + mock_view.getSize = MagicMock(return_value=(800, 600)) + task = CommandInsertLink.TaskAssemblyInsertLink(self.assembly, mock_view) + + # Create a mix of valid and invalid objects + test_objects = [ + # Valid object (would work in real scenario) + { + "addedObject": type( + "ValidObject", + (), + { + "Name": "ValidObject", + "Label": "Valid Object", + "LinkedObject": type( + "ValidLinkedObject", (), {"Name": "ValidLinkedObjectName"} + )(), + }, + )(), + "translation": App.Vector(1, 1, 1), + }, + # Invalid: LinkedObject is None + { + "addedObject": type( + "InvalidObject1", + (), + {"Name": "InvalidObject1", "Label": "Invalid Object 1", "LinkedObject": None}, + )(), + "translation": App.Vector(2, 2, 2), + }, + # Invalid: No LinkedObject attribute + { + "addedObject": type( + "InvalidObject2", (), {"Name": "InvalidObject2", "Label": "Invalid Object 2"} + )(), + "translation": App.Vector(3, 3, 3), + }, + # Invalid: LinkedObject.Name is None + { + "addedObject": type( + "InvalidObject3", + (), + { + "Name": "InvalidObject3", + "Label": "Invalid Object 3", + "LinkedObject": type("LinkedObjectWithNoneName", (), {"Name": None})(), + }, + )(), + "translation": App.Vector(4, 4, 4), + }, + # Invalid: No Name attribute + { + "addedObject": type( + "InvalidObject4", + (), + { + "Label": "Invalid Object 4", + "LinkedObject": type("LinkedObject", (), {"Name": "Something"})(), + }, + )(), + "translation": App.Vector(5, 5, 5), + }, + ] + + # Add all objects to insertion stack + for obj_data in test_objects: + task.insertionStack.append(obj_data) + + # Should handle the mix gracefully - invalid objects skipped, valid ones processed + result = task.accept() + self.assertTrue(result, "accept() should return True even with mixed valid/invalid objects") + _msg(" Successfully handled mixed valid/invalid objects") + + @patch("FreeCADGui.PySideUic.loadUi") + @patch("CommandInsertLink.TaskAssemblyInsertLink.adjustTreeWidgetSize") + def test_empty_insertion_stack(self, _mock_adjustTreeSize, _mock_ui): + """Test that accept() handles empty insertion stack correctly.""" + operation = "Handle empty insertion stack" + _msg(f" Test '{operation}'") + + mock_view = MagicMock() + mock_view.getSize = MagicMock(return_value=(800, 600)) + task = CommandInsertLink.TaskAssemblyInsertLink(self.assembly, mock_view) + + # Don't add anything to insertion stack - it should remain empty + self.assertEqual(len(task.insertionStack), 0, "Insertion stack should be empty") + + result = task.accept() + self.assertTrue(result, "accept() should return True even with empty insertion stack") + _msg(" Successfully handled empty insertion stack") diff --git a/src/Mod/Assembly/AssemblyTests/mocks/MockGui.py b/src/Mod/Assembly/AssemblyTests/mocks/MockGui.py new file mode 100644 index 0000000000..4fa28b6f48 --- /dev/null +++ b/src/Mod/Assembly/AssemblyTests/mocks/MockGui.py @@ -0,0 +1,180 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# /**************************************************************************** +# * +# Copyright (c) 2025 Weston Schmidt * +# * +# This file is part of FreeCAD. * +# * +# FreeCAD is free software: you can redistribute it and/or modify it * +# under the terms of the GNU Lesser General Public License as * +# published by the Free Software Foundation, either version 2.1 of the * +# License, or (at your option) any later version. * +# * +# FreeCAD is distributed in the hope that it will be useful, but * +# WITHOUT ANY WARRANTY; without even the implied warranty of * +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# Lesser General Public License for more details. * +# * +# You should have received a copy of the GNU Lesser General Public * +# License along with FreeCAD. If not, see * +# . * +# * +# ***************************************************************************/ + +""" +Mock classes for FreeCAD GUI testing. + +This module provides mock implementations of FreeCAD and Qt classes +to enable unit testing without requiring the full FreeCAD environment. +""" + +# pylint: disable=too-few-public-methods + +from unittest.mock import MagicMock + + +def create_mock_qicon(): + """Create a mock QIcon with fromTheme static method.""" + mock_qicon = MagicMock() + mock_qicon.fromTheme = MagicMock(return_value=mock_qicon) + return mock_qicon + + +def create_mock_qtreewidgetitem(): + """Create a mock QTreeWidgetItem with required methods and state tracking.""" + mock_item = MagicMock() + + # Add state tracking for setText/text functionality + mock_item.text_values = {} + mock_item.data_values = {} + mock_item.children = [] + + def mock_set_text(column, text): + mock_item.text_values[column] = text + + def mock_get_text(column): + return mock_item.text_values.get(column, "") + + def mock_set_data(column, role, data): + mock_item.data_values[(column, role)] = data + + def mock_get_data(column, role): + return mock_item.data_values.get((column, role)) + + def mock_child_count(): + return len(mock_item.children) + + def mock_child(index): + if 0 <= index < len(mock_item.children): + return mock_item.children[index] + return None + + def mock_add_child(child): + mock_item.children.append(child) + + # Configure the mock with specific behaviors + mock_item.setText = mock_set_text + mock_item.text = mock_get_text + mock_item.setData = mock_set_data + mock_item.data = mock_get_data + mock_item.childCount = mock_child_count + mock_item.child = mock_child + mock_item.addChild = mock_add_child + + return mock_item + + +def create_mock_checkbox(): + """Create a mock CheckBox with state tracking.""" + mock_checkbox = MagicMock() + mock_checkbox.checked = False + + def mock_set_checked(checked): + mock_checkbox.checked = checked + + def mock_is_checked(): + return mock_checkbox.checked + + mock_checkbox.setChecked = mock_set_checked + mock_checkbox.isChecked = mock_is_checked + mock_checkbox.stateChanged = MagicMock() + + return mock_checkbox + + +def create_mock_line_edit(): + """Create a mock LineEdit.""" + mock_line_edit = MagicMock() + mock_line_edit.text.return_value = "" + mock_line_edit.textChanged = MagicMock() + return mock_line_edit + + +def create_mock_part_list(): + """Create a mock PartList with required functionality.""" + mock_part_list = MagicMock() + mock_part_list.items = [] + + def mock_clear(): + mock_part_list.items = [] + + def mock_add_top_level_item(item): + mock_part_list.items.append(item) + + def mock_top_level_item_count(): + return len(mock_part_list.items) + + def mock_top_level_item(index): + if 0 <= index < len(mock_part_list.items): + return mock_part_list.items[index] + return None + + mock_part_list.clear = mock_clear + mock_part_list.addTopLevelItem = mock_add_top_level_item + mock_part_list.topLevelItemCount = mock_top_level_item_count + mock_part_list.topLevelItem = mock_top_level_item + mock_part_list.sizeHintForRow.return_value = 20 + + # Add other required attributes/methods + mock_part_list.itemClicked = MagicMock() + mock_part_list.itemDoubleClicked = MagicMock() + mock_part_list.header.return_value = MagicMock() + + return mock_part_list + + +def create_mock_form(): + """Create a mock Form with all required components.""" + mock_form = MagicMock() + mock_form.partList = create_mock_part_list() + mock_form.CheckBox_ShowOnlyParts = create_mock_checkbox() + mock_form.CheckBox_RigidSubAsm = create_mock_checkbox() + mock_form.openFileButton = MagicMock() + mock_form.openFileButton.clicked = MagicMock() + mock_form.filterPartList = create_mock_line_edit() + return mock_form + + +def create_mock_pyside_uic(): + """Create a mock PySideUic with loadUi method.""" + mock_uic = MagicMock() + mock_uic.loadUi.return_value = create_mock_form() + return mock_uic + + +def create_mock_gui_document(doc_name): + """Create a mock GUI document.""" + mock_doc = MagicMock() + mock_doc.Name = doc_name + mock_doc.getObject.return_value = None + mock_doc.TreeRootObjects = [] + return mock_doc + + +# Factory functions for creating specific mock instances +MockQIcon = create_mock_qicon +MockQTreeWidgetItem = create_mock_qtreewidgetitem +MockPySideUic = create_mock_pyside_uic +MockGetDocument = create_mock_gui_document +MockAddModule = MagicMock() +MockDoCommandSkip = MagicMock() diff --git a/src/Mod/Assembly/AssemblyTests/mocks/__init__.py b/src/Mod/Assembly/AssemblyTests/mocks/__init__.py new file mode 100644 index 0000000000..26b28763c2 --- /dev/null +++ b/src/Mod/Assembly/AssemblyTests/mocks/__init__.py @@ -0,0 +1,80 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# /**************************************************************************** +# * +# Copyright (c) 2025 Weston Schmidt * +# * +# This file is part of FreeCAD. * +# * +# FreeCAD is free software: you can redistribute it and/or modify it * +# under the terms of the GNU Lesser General Public License as * +# published by the Free Software Foundation, either version 2.1 of the * +# License, or (at your option) any later version. * +# * +# FreeCAD is distributed in the hope that it will be useful, but * +# WITHOUT ANY WARRANTY; without even the implied warranty of * +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# Lesser General Public License for more details. * +# * +# You should have received a copy of the GNU Lesser General Public * +# License along with FreeCAD. If not, see * +# . * +# * +# ***************************************************************************/ + +"""Mock classes for FreeCAD GUI testing.""" + +import builtins +from .MockGui import ( + MockQIcon, + MockQTreeWidgetItem, + MockPySideUic, + MockGetDocument, + MockAddModule, + MockDoCommandSkip, +) + +# Set up all FreeCAD GUI mocks for testing +# Always create mocks for consistent testing +Gui = type("MockGui", (), {})() +QtCore = type("MockQtCore", (), {})() +QtGui = type("MockQtGui", (), {})() + +# Patch QtGui with our mock classes +QtGui.QIcon = MockQIcon +QtGui.QTreeWidgetItem = MockQTreeWidgetItem + +# Mock the PySideUic if it doesn't exist +if not hasattr(Gui, "PySideUic"): + Gui.PySideUic = MockPySideUic + +# Mock additional Gui methods that might be missing +if not hasattr(Gui, "getDocument"): + Gui.getDocument = MockGetDocument + +# Mock Selection module +if not hasattr(Gui, "Selection"): + Gui.Selection = type( + "MockSelection", + (), + { + "clearSelection": lambda *args: None, + "addSelection": lambda *args: None, + "getSelection": lambda *args: [], + }, + )() + +# Mock addModule method +if not hasattr(Gui, "addModule"): + Gui.addModule = MockAddModule + +# Mock doCommandSkip method +if not hasattr(Gui, "doCommandSkip"): + Gui.doCommandSkip = MockDoCommandSkip + +# Make QtCore, QtGui and Gui available in the global namespace +builtins.QtCore = QtCore +builtins.QtGui = QtGui +builtins.Gui = Gui +builtins.QIcon = MockQIcon + +__all__ = [] diff --git a/src/Mod/Assembly/CMakeLists.txt b/src/Mod/Assembly/CMakeLists.txt index 42149923e3..407e58744f 100644 --- a/src/Mod/Assembly/CMakeLists.txt +++ b/src/Mod/Assembly/CMakeLists.txt @@ -53,6 +53,9 @@ SET(AssemblyScripts_SRCS SET(AssemblyTests_SRCS AssemblyTests/__init__.py AssemblyTests/TestCore.py + AssemblyTests/TestCommandInsertLink.py + AssemblyTests/mocks/__init__.py + AssemblyTests/mocks/MockGui.py ) diff --git a/src/Mod/Assembly/TestAssemblyWorkbench.py b/src/Mod/Assembly/TestAssemblyWorkbench.py index a9d404fd54..911a298a6a 100644 --- a/src/Mod/Assembly/TestAssemblyWorkbench.py +++ b/src/Mod/Assembly/TestAssemblyWorkbench.py @@ -24,7 +24,9 @@ import TestApp from AssemblyTests.TestCore import TestCore +from AssemblyTests.TestCommandInsertLink import TestCommandInsertLink # Use the modules so that code checkers don't complain (flake8) True if TestCore else False +True if TestCommandInsertLink else False