Addon Manager: Refactor installation code

Improve testability of installation code by refactoring it to completely
separate the GUI and non-GUI code, and to provide more robust support
for non-GUI access to some type of Addon Manager activity.
This commit is contained in:
Chris Hennes
2022-11-18 18:51:04 -06:00
parent 403e0dc477
commit 89c191e160
46 changed files with 4012 additions and 1666 deletions

View File

@@ -0,0 +1,22 @@
# ***************************************************************************
# * *
# * Copyright (c) 2022 FreeCAD Project Association *
# * *
# * 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 *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************

View File

@@ -0,0 +1,750 @@
# ***************************************************************************
# * *
# * Copyright (c) 2022 FreeCAD Project Association *
# * *
# * 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 *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
import os
import tempfile
import unittest
import FreeCAD
from PySide import QtCore, QtWidgets
from addonmanager_installer_gui import AddonInstallerGUI, MacroInstallerGUI
translate = FreeCAD.Qt.translate
class DialogWatcher(QtCore.QObject):
def __init__(self, dialog_to_watch_for, button):
super().__init__()
self.dialog_found = False
self.has_run = False
self.dialog_to_watch_for = dialog_to_watch_for
self.button = button
def run(self):
widget = QtWidgets.QApplication.activeModalWidget()
if widget:
# Is this the widget we are looking for?
if (
hasattr(widget, "windowTitle")
and callable(widget.windowTitle)
and widget.windowTitle() == self.dialog_to_watch_for
):
# Found the dialog we are looking for: now try to "click" the appropriate button
self.click_button(widget)
self.dialog_found = True
self.has_run = True
def click_button(self, widget):
buttons = widget.findChildren(QtWidgets.QPushButton)
for button in buttons:
text = button.text().replace("&", "")
if text == self.button:
button.click()
class DialogInteractor(DialogWatcher):
def __init__(self, dialog_to_watch_for, interaction):
"""Takes the title of the dialog, a button string, and a callable."""
super().__init__(dialog_to_watch_for, None)
self.interaction = interaction
def run(self):
widget = QtWidgets.QApplication.activeModalWidget()
if widget:
# Is this the widget we are looking for?
if (
hasattr(widget, "windowTitle")
and callable(widget.windowTitle)
and widget.windowTitle() == self.dialog_to_watch_for
):
# Found the dialog we are looking for: now try to "click" the appropriate button
self.dialog_found = True
if self.dialog_found:
self.has_run = True
if self.interaction is not None and callable(self.interaction):
self.interaction(widget)
class TestInstallerGui(unittest.TestCase):
MODULE = "test_installer_gui" # file name without extension
class MockAddon:
def __init__(self):
test_dir = os.path.join(
FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data"
)
self.name = "MockAddon"
self.display_name = "Mock Addon"
self.url = os.path.join(test_dir, "test_simple_repo.zip")
self.branch = "main"
def setUp(self):
self.addon_to_install = TestInstallerGui.MockAddon()
self.installer_gui = AddonInstallerGUI(self.addon_to_install)
self.finalized_thread = False
def tearDown(self):
pass
def test_success_dialog(self):
# Pop the modal dialog and verify that it opens, and responds to an OK click
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Success"), translate("AddonsInstaller", "OK")
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
self.installer_gui._installation_succeeded()
self.assertTrue(
dialog_watcher.dialog_found, "Failed to find the expected dialog box"
)
def test_failure_dialog(self):
# Pop the modal dialog and verify that it opens, and responds to a Cancel click
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Installation Failed"),
translate("AddonsInstaller", "Cancel"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
self.installer_gui._installation_failed(
self.addon_to_install, "Test of installation failure"
)
self.assertTrue(
dialog_watcher.dialog_found, "Failed to find the expected dialog box"
)
def test_no_python_dialog(self):
# Pop the modal dialog and verify that it opens, and responds to a No click
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Cannot execute Python"),
translate("AddonsInstaller", "No"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
self.installer_gui._report_no_python_exe()
self.assertTrue(
dialog_watcher.dialog_found, "Failed to find the expected dialog box"
)
def test_no_pip_dialog(self):
# Pop the modal dialog and verify that it opens, and responds to a No click
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Cannot execute pip"),
translate("AddonsInstaller", "No"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
self.installer_gui._report_no_pip("pip not actually run, this was a test")
self.assertTrue(
dialog_watcher.dialog_found, "Failed to find the expected dialog box"
)
def test_dependency_failure_dialog(self):
# Pop the modal dialog and verify that it opens, and responds to a No click
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Package installation failed"),
translate("AddonsInstaller", "No"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
self.installer_gui._report_dependency_failure(
"Unit test", "Nothing really failed, this is a test of the dialog box"
)
self.assertTrue(
dialog_watcher.dialog_found, "Failed to find the expected dialog box"
)
def test_install(self):
# Run the installation code and make sure it puts the directory in place
with tempfile.TemporaryDirectory() as temp_dir:
self.installer_gui.installer.installation_path = temp_dir
self.installer_gui.install() # This does not block
self.installer_gui.installer.success.disconnect(
self.installer_gui._installation_succeeded
)
self.installer_gui.installer.failure.disconnect(
self.installer_gui._installation_failed
)
while not self.installer_gui.worker_thread.isFinished():
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 100)
self.assertTrue(
os.path.exists(os.path.join(temp_dir, "MockAddon")),
"Installed directory not found",
)
def test_handle_disallowed_python(self):
disallowed_packages = ["disallowed_package_name"]
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Missing Requirement"),
translate("AddonsInstaller", "Cancel"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
self.installer_gui._handle_disallowed_python(disallowed_packages)
self.assertTrue(
dialog_watcher.dialog_found, "Failed to find the expected dialog box"
)
def test_handle_disallowed_python_long_list(self):
"""A separate test for when there are MANY packages, which takes a separate code path."""
disallowed_packages = []
for i in range(50):
disallowed_packages.append(f"disallowed_package_name_{i}")
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Missing Requirement"),
translate("AddonsInstaller", "Cancel"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
self.installer_gui._handle_disallowed_python(disallowed_packages)
self.assertTrue(
dialog_watcher.dialog_found, "Failed to find the expected dialog box"
)
def test_report_missing_workbenches_single(self):
"""Test only missing one workbench"""
wbs = ["OneMissingWorkbench"]
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Missing Requirement"),
translate("AddonsInstaller", "Cancel"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
self.installer_gui._report_missing_workbenches(wbs)
self.assertTrue(
dialog_watcher.dialog_found, "Failed to find the expected dialog box"
)
def test_report_missing_workbenches_multiple(self):
"""Test only missing one workbench"""
wbs = ["FirstMissingWorkbench", "SecondMissingWorkbench"]
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Missing Requirement"),
translate("AddonsInstaller", "Cancel"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
self.installer_gui._report_missing_workbenches(wbs)
self.assertTrue(
dialog_watcher.dialog_found, "Failed to find the expected dialog box"
)
def test_resolve_dependencies_then_install(self):
class MissingDependenciesMock:
def __init__(self):
self.external_addons = ["addon_1", "addon_2"]
self.python_requires = ["py_req_1", "py_req_2"]
self.python_optional = ["py_opt_1", "py_opt_2"]
missing = MissingDependenciesMock()
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Resolve Dependencies"),
translate("AddonsInstaller", "Cancel"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
self.installer_gui._resolve_dependencies_then_install(missing)
self.assertTrue(
dialog_watcher.dialog_found, "Failed to find the expected dialog box"
)
def test_check_python_version_bad(self):
class MissingDependenciesMock:
def __init__(self):
self.python_min_version = {"major": 3, "minor": 9999}
missing = MissingDependenciesMock()
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Incompatible Python version"),
translate("AddonsInstaller", "Cancel"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
stop_installing = self.installer_gui._check_python_version(missing)
self.assertTrue(
dialog_watcher.dialog_found, "Failed to find the expected dialog box"
)
self.assertTrue(
stop_installing, "Failed to halt installation on bad Python version"
)
def test_check_python_version_good(self):
class MissingDependenciesMock:
def __init__(self):
self.python_min_version = {"major": 3, "minor": 0}
missing = MissingDependenciesMock()
stop_installing = self.installer_gui._check_python_version(missing)
self.assertFalse(
stop_installing, "Failed to continue installation on good Python version"
)
def test_clean_up_optional(self):
class MissingDependenciesMock:
def __init__(self):
self.python_optional = [
"allowed_packages_1",
"allowed_packages_2",
"disallowed_package",
]
allowed_packages = ["allowed_packages_1", "allowed_packages_2"]
missing = MissingDependenciesMock()
self.installer_gui.installer.allowed_packages = set(allowed_packages)
self.installer_gui._clean_up_optional(missing)
self.assertTrue("allowed_packages_1" in missing.python_optional)
self.assertTrue("allowed_packages_2" in missing.python_optional)
self.assertFalse("disallowed_package" in missing.python_optional)
def intercept_run_dependency_installer(
self, addons, python_requires, python_optional
):
self.assertEqual(python_requires, ["py_req_1", "py_req_2"])
self.assertEqual(python_optional, ["py_opt_1", "py_opt_2"])
self.assertEqual(addons[0].name, "addon_1")
self.assertEqual(addons[1].name, "addon_2")
def test_dependency_dialog_yes_clicked(self):
class DialogMock:
class ListWidgetMock:
class ListWidgetItemMock:
def __init__(self, name):
self.name = name
def text(self):
return self.name
def checkState(self):
return QtCore.Qt.Checked
def __init__(self, items):
self.list = []
for item in items:
self.list.append(
DialogMock.ListWidgetMock.ListWidgetItemMock(item)
)
def count(self):
return len(self.list)
def item(self, i):
return self.list[i]
def __init__(self):
self.listWidgetAddons = DialogMock.ListWidgetMock(
["addon_1", "addon_2"]
)
self.listWidgetPythonRequired = DialogMock.ListWidgetMock(
["py_req_1", "py_req_2"]
)
self.listWidgetPythonOptional = DialogMock.ListWidgetMock(
["py_opt_1", "py_opt_2"]
)
class AddonMock:
def __init__(self, name):
self.name = name
self.installer_gui.dependency_dialog = DialogMock()
self.installer_gui.addons = [AddonMock("addon_1"), AddonMock("addon_2")]
self.installer_gui._run_dependency_installer = (
self.intercept_run_dependency_installer
)
self.installer_gui._dependency_dialog_yes_clicked()
class TestMacroInstallerGui(unittest.TestCase):
class MockMacroAddon:
class MockMacro:
def __init__(self):
self.install_called = False
self.install_result = (
True # External code can change to False to test failed install
)
self.name = "MockMacro"
self.filename = "mock_macro_no_real_file.FCMacro"
self.comment = "This is a mock macro for unit testing"
self.icon = None
self.xpm = None
def install(self):
self.install_called = True
return self.install_result
def __init__(self):
self.macro = TestMacroInstallerGui.MockMacroAddon.MockMacro()
self.name = self.macro.name
self.display_name = self.macro.name
class MockParameter:
"""Mock the parameter group to allow simplified behavior and introspection."""
def __init__(self):
self.params = {}
self.groups = {}
self.accessed_parameters = {} # Dict is param name: default value
types = ["Bool", "String", "Int", "UInt", "Float"]
for t in types:
setattr(self, f"Get{t}", self.get)
setattr(self, f"Set{t}", self.set)
setattr(self, f"Rem{t}", self.rem)
def get(self, p, default=None):
self.accessed_parameters[p] = default
if p in self.params:
return self.params[p]
else:
return default
def set(self, p, value):
self.params[p] = value
def rem(self, p):
if p in self.params:
self.params.erase(p)
def GetGroup(self, name):
if name not in self.groups:
self.groups[name] = TestMacroInstallerGui.MockParameter()
return self.groups[name]
def GetGroups(self):
return self.groups.keys()
class ToolbarIntercepter:
def __init__(self):
self.ask_for_toolbar_called = False
self.install_macro_to_toolbar_called = False
self.tb = None
self.custom_group = TestMacroInstallerGui.MockParameter()
self.custom_group.set("Name", "MockCustomToolbar")
def _ask_for_toolbar(self, _):
self.ask_for_toolbar_called = True
return self.custom_group
def _install_macro_to_toolbar(self, tb):
self.install_macro_to_toolbar_called = True
self.tb = tb
class InstallerInterceptor:
def __init__(self):
self.ccc_called = False
def _create_custom_command(
self,
toolbar,
filename,
menuText,
tooltipText,
whatsThisText,
statustipText,
pixmapText,
):
self.ccc_called = True
self.toolbar = toolbar
self.filename = filename
self.menuText = menuText
self.tooltipText = tooltipText
self.whatsThisText = whatsThisText
self.statustipText = statustipText
self.pixmapText = pixmapText
def setUp(self):
self.mock_macro = TestMacroInstallerGui.MockMacroAddon()
self.installer = MacroInstallerGUI(self.mock_macro)
self.installer.addon_params = TestMacroInstallerGui.MockParameter()
self.installer.toolbar_params = TestMacroInstallerGui.MockParameter()
def tearDown(self):
pass
def test_ask_for_toolbar_no_dialog_default_exists(self):
self.installer.addon_params.set("alwaysAskForToolbar", False)
self.installer.addon_params.set("CustomToolbarName", "UnitTestCustomToolbar")
utct = self.installer.toolbar_params.GetGroup("UnitTestCustomToolbar")
utct.set("Name", "UnitTestCustomToolbar")
utct.set("Active", True)
result = self.installer._ask_for_toolbar([])
self.assertIsNotNone(result)
self.assertTrue(hasattr(result, "get"))
name = result.get("Name")
self.assertEqual(name, "UnitTestCustomToolbar")
def test_ask_for_toolbar_with_dialog_cancelled(self):
# First test: the user cancels the dialog
self.installer.addon_params.set("alwaysAskForToolbar", True)
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Select Toolbar"),
translate("AddonsInstaller", "Cancel"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
result = self.installer._ask_for_toolbar([])
self.assertIsNone(result)
def test_ask_for_toolbar_with_dialog_defaults(self):
# Second test: the user leaves the dialog at all default values, so:
# - The checkbox "Ask every time" is unchecked
# - The selected toolbar option is "Create new toolbar", which triggers a search for
# a new custom toolbar name by calling _create_new_custom_toolbar, which we mock.
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Select Toolbar"),
translate("AddonsInstaller", "Cancel"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
fake_custom_toolbar_group = TestMacroInstallerGui.MockParameter()
fake_custom_toolbar_group.set("Name", "UnitTestCustomToolbar")
self.installer._create_new_custom_toolbar = lambda: fake_custom_toolbar_group
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Select Toolbar"),
translate("AddonsInstaller", "OK"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
result = self.installer._ask_for_toolbar([])
self.assertIsNotNone(result)
self.assertTrue(hasattr(result, "get"))
name = result.get("Name")
self.assertEqual(name, "UnitTestCustomToolbar")
self.assertIn("alwaysAskForToolbar", self.installer.addon_params.params)
self.assertFalse(self.installer.addon_params.get("alwaysAskForToolbar", True))
def test_ask_for_toolbar_with_dialog_selection(self):
# Third test: the user selects a custom toolbar in the dialog, and checks the box to always
# ask.
dialog_interactor = DialogInteractor(
translate("AddonsInstaller", "Select Toolbar"),
self.interactor_selection_option_and_checkbox,
)
QtCore.QTimer.singleShot(10, dialog_interactor.run)
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_2 = self.installer.toolbar_params.GetGroup("UT_TB_2")
ut_tb_3 = self.installer.toolbar_params.GetGroup("UT_TB_3")
ut_tb_1.set("Name", "UT_TB_1")
ut_tb_2.set("Name", "UT_TB_2")
ut_tb_3.set("Name", "UT_TB_3")
result = self.installer._ask_for_toolbar(["UT_TB_1", "UT_TB_2", "UT_TB_3"])
self.assertIsNotNone(result)
self.assertTrue(hasattr(result, "get"))
name = result.get("Name")
self.assertEqual(name, "UT_TB_3")
self.assertIn("alwaysAskForToolbar", self.installer.addon_params.params)
self.assertTrue(self.installer.addon_params.get("alwaysAskForToolbar", False))
def interactor_selection_option_and_checkbox(self, parent):
boxes = parent.findChildren(QtWidgets.QComboBox)
self.assertEqual(len(boxes), 1) # Just to make sure...
box = boxes[0]
box.setCurrentIndex(box.count() - 2) # Select the last thing but one
checkboxes = parent.findChildren(QtWidgets.QCheckBox)
self.assertEqual(len(checkboxes), 1) # Just to make sure...
checkbox = checkboxes[0]
checkbox.setChecked(True)
parent.accept()
def test_macro_button_exists_no_command(self):
# Test 1: No command for this macro
self.installer._find_custom_command = lambda _: None
button_exists = self.installer._macro_button_exists()
self.assertFalse(button_exists)
def test_macro_button_exists_true(self):
# Test 2: Macro is in the list of buttons
ut_tb_1 = self.installer.toolbar_params.GetGroup("UnitTestCommand")
ut_tb_1.set(
"UnitTestCommand", "FreeCAD"
) # This is what the real thing looks like...
self.installer._find_custom_command = lambda _: "UnitTestCommand"
self.assertTrue(self.installer._macro_button_exists())
def test_macro_button_exists_false(self):
# Test 3: Macro is not in the list of buttons
self.installer._find_custom_command = lambda _: "UnitTestCommand"
self.assertFalse(self.installer._macro_button_exists())
def test_ask_to_install_toolbar_button_disabled(self):
self.installer.addon_params.SetBool("dontShowAddMacroButtonDialog", True)
self.installer._ask_to_install_toolbar_button()
# This should NOT block when dontShowAddMacroButtonDialog is True
def test_ask_to_install_toolbar_button_enabled_no(self):
self.installer.addon_params.SetBool("dontShowAddMacroButtonDialog", False)
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Add button?"),
translate("AddonsInstaller", "No"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
self.installer._ask_to_install_toolbar_button() # Blocks until killed by watcher
self.assertTrue(dialog_watcher.dialog_found)
def test_get_toolbar_with_name_found(self):
ut_tb_1 = self.installer.toolbar_params.GetGroup("UnitTestToolbar")
ut_tb_1.set("Name", "Unit Test Toolbar")
ut_tb_1.set("UnitTestParam", True)
tb = self.installer._get_toolbar_with_name("Unit Test Toolbar")
self.assertIsNotNone(tb)
self.assertTrue(tb.get("UnitTestParam", False))
def test_get_toolbar_with_name_not_found(self):
ut_tb_1 = self.installer.toolbar_params.GetGroup("UnitTestToolbar")
ut_tb_1.set("Name", "Not the Unit Test Toolbar")
tb = self.installer._get_toolbar_with_name("Unit Test Toolbar")
self.assertIsNone(tb)
def test_create_new_custom_toolbar_no_existing(self):
tb = self.installer._create_new_custom_toolbar()
self.assertEqual(tb.get("Name", ""), "Auto-Created Macro Toolbar")
self.assertTrue(tb.get("Active", False), True)
def test_create_new_custom_toolbar_one_existing(self):
_ = self.installer._create_new_custom_toolbar()
tb = self.installer._create_new_custom_toolbar()
self.assertEqual(tb.get("Name", ""), "Auto-Created Macro Toolbar (2)")
self.assertTrue(tb.get("Active", False), True)
def test_check_for_toolbar_true(self):
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_1.set("Name", "UT_TB_1")
self.assertTrue(self.installer._check_for_toolbar("UT_TB_1"))
def test_check_for_toolbar_false(self):
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_1.set("Name", "UT_TB_1")
self.assertFalse(self.installer._check_for_toolbar("Not UT_TB_1"))
def test_install_toolbar_button_first_custom_toolbar(self):
tbi = TestMacroInstallerGui.ToolbarIntercepter()
self.installer._ask_for_toolbar = tbi._ask_for_toolbar
self.installer._install_macro_to_toolbar = tbi._install_macro_to_toolbar
self.installer._install_toolbar_button()
self.assertTrue(tbi.install_macro_to_toolbar_called)
self.assertFalse(tbi.ask_for_toolbar_called)
self.assertTrue("Custom_1" in self.installer.toolbar_params.GetGroups())
def test_install_toolbar_button_existing_custom_toolbar_1(self):
# There is an existing custom toolbar, and we should use it
tbi = TestMacroInstallerGui.ToolbarIntercepter()
self.installer._ask_for_toolbar = tbi._ask_for_toolbar
self.installer._install_macro_to_toolbar = tbi._install_macro_to_toolbar
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_1.set("Name", "UT_TB_1")
self.installer.addon_params.set("CustomToolbarName", "UT_TB_1")
self.installer._install_toolbar_button()
self.assertTrue(tbi.install_macro_to_toolbar_called)
self.assertFalse(tbi.ask_for_toolbar_called)
self.assertEqual(tbi.tb.get("Name", ""), "UT_TB_1")
def test_install_toolbar_button_existing_custom_toolbar_2(self):
# There are multiple existing custom toolbars, and we should use one of them
tbi = TestMacroInstallerGui.ToolbarIntercepter()
self.installer._ask_for_toolbar = tbi._ask_for_toolbar
self.installer._install_macro_to_toolbar = tbi._install_macro_to_toolbar
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_2 = self.installer.toolbar_params.GetGroup("UT_TB_2")
ut_tb_3 = self.installer.toolbar_params.GetGroup("UT_TB_3")
ut_tb_1.set("Name", "UT_TB_1")
ut_tb_2.set("Name", "UT_TB_2")
ut_tb_3.set("Name", "UT_TB_3")
self.installer.addon_params.set("CustomToolbarName", "UT_TB_3")
self.installer._install_toolbar_button()
self.assertTrue(tbi.install_macro_to_toolbar_called)
self.assertFalse(tbi.ask_for_toolbar_called)
self.assertEqual(tbi.tb.get("Name", ""), "UT_TB_3")
def test_install_toolbar_button_existing_custom_toolbar_3(self):
# There are multiple existing custom toolbars, but none of them match
tbi = TestMacroInstallerGui.ToolbarIntercepter()
self.installer._ask_for_toolbar = tbi._ask_for_toolbar
self.installer._install_macro_to_toolbar = tbi._install_macro_to_toolbar
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_2 = self.installer.toolbar_params.GetGroup("UT_TB_2")
ut_tb_3 = self.installer.toolbar_params.GetGroup("UT_TB_3")
ut_tb_1.set("Name", "UT_TB_1")
ut_tb_2.set("Name", "UT_TB_2")
ut_tb_3.set("Name", "UT_TB_3")
self.installer.addon_params.set("CustomToolbarName", "UT_TB_4")
self.installer._install_toolbar_button()
self.assertTrue(tbi.install_macro_to_toolbar_called)
self.assertTrue(tbi.ask_for_toolbar_called)
self.assertEqual(tbi.tb.get("Name", ""), "MockCustomToolbar")
def test_install_toolbar_button_existing_custom_toolbar_4(self):
# There are multiple existing custom toolbars, one of them matches, but we have set
# "alwaysAskForToolbar" to True
tbi = TestMacroInstallerGui.ToolbarIntercepter()
self.installer._ask_for_toolbar = tbi._ask_for_toolbar
self.installer._install_macro_to_toolbar = tbi._install_macro_to_toolbar
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_2 = self.installer.toolbar_params.GetGroup("UT_TB_2")
ut_tb_3 = self.installer.toolbar_params.GetGroup("UT_TB_3")
ut_tb_1.set("Name", "UT_TB_1")
ut_tb_2.set("Name", "UT_TB_2")
ut_tb_3.set("Name", "UT_TB_3")
self.installer.addon_params.set("CustomToolbarName", "UT_TB_3")
self.installer.addon_params.set("alwaysAskForToolbar", True)
self.installer._install_toolbar_button()
self.assertTrue(tbi.install_macro_to_toolbar_called)
self.assertTrue(tbi.ask_for_toolbar_called)
self.assertEqual(tbi.tb.get("Name", ""), "MockCustomToolbar")
def test_install_macro_to_toolbar_icon_abspath(self):
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_1.set("Name", "UT_TB_1")
ii = TestMacroInstallerGui.InstallerInterceptor()
self.installer._create_custom_command = ii._create_custom_command
with tempfile.NamedTemporaryFile() as ntf:
self.mock_macro.macro.icon = ntf.name
self.installer._install_macro_to_toolbar(ut_tb_1)
self.assertTrue(ii.ccc_called)
self.assertEqual(ii.pixmapText, ntf.name)
def test_install_macro_to_toolbar_icon_relpath(self):
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_1.set("Name", "UT_TB_1")
ii = TestMacroInstallerGui.InstallerInterceptor()
self.installer._create_custom_command = ii._create_custom_command
with tempfile.TemporaryDirectory() as td:
self.installer.macro_dir = td
self.mock_macro.macro.icon = "RelativeIconPath.png"
self.installer._install_macro_to_toolbar(ut_tb_1)
self.assertTrue(ii.ccc_called)
self.assertEqual(ii.pixmapText, os.path.join(td, "RelativeIconPath.png"))
def test_install_macro_to_toolbar_xpm(self):
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_1.set("Name", "UT_TB_1")
ii = TestMacroInstallerGui.InstallerInterceptor()
self.installer._create_custom_command = ii._create_custom_command
with tempfile.TemporaryDirectory() as td:
self.installer.macro_dir = td
self.mock_macro.macro.xpm = "Not really xpm data, don't try to use it!"
self.installer._install_macro_to_toolbar(ut_tb_1)
self.assertTrue(ii.ccc_called)
self.assertEqual(ii.pixmapText, os.path.join(td, "MockMacro_icon.xpm"))
self.assertTrue(os.path.exists(os.path.join(td, "MockMacro_icon.xpm")))
def test_install_macro_to_toolbar_no_icon(self):
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_1.set("Name", "UT_TB_1")
ii = TestMacroInstallerGui.InstallerInterceptor()
self.installer._create_custom_command = ii._create_custom_command
with tempfile.TemporaryDirectory() as td:
self.installer.macro_dir = td
self.installer._install_macro_to_toolbar(ut_tb_1)
self.assertTrue(ii.ccc_called)
self.assertIsNone(ii.pixmapText)

View File

@@ -0,0 +1,246 @@
# ***************************************************************************
# * *
# * Copyright (c) 2022 FreeCAD Project Association *
# * *
# * 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 *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
from time import sleep
import unittest
import FreeCAD
from Addon import Addon
from PySide import QtCore, QtWidgets
from addonmanager_update_all_gui import UpdateAllGUI, AddonStatus
class MockUpdater(QtCore.QObject):
success = QtCore.Signal(object)
failure = QtCore.Signal(object)
finished = QtCore.Signal()
def __init__(self, addon, addons=[]):
super().__init__()
self.addon_to_install = addon
self.addons = addons
self.has_run = False
self.emit_success = True
self.work_function = (
None # Set to some kind of callable to make this function take time
)
def run(self):
self.has_run = True
if self.work_function is not None and callable(self.work_function):
self.work_function()
if self.emit_success:
self.success.emit(self.addon_to_install)
else:
self.failure.emit(self.addon_to_install)
self.finished.emit()
class MockUpdaterFactory:
def __init__(self, addons):
self.addons = addons
self.work_function = None
self.updater = None
def get_updater(self, addon):
self.updater = MockUpdater(addon, self.addons)
self.updater.work_function = self.work_function
return self.updater
class MockAddon:
def __init__(self, name):
self.display_name = name
self.name = name
self.macro = None
def status(self):
return Addon.Status.UPDATE_AVAILABLE
class CallInterceptor:
def __init__(self):
self.called = False
self.args = None
def intercept(self, *args):
self.called = True
self.args = args
class TestUpdateAllGui(unittest.TestCase):
def setUp(self):
self.addons = []
for i in range(3):
self.addons.append(MockAddon(f"Mock Addon {i}"))
self.factory = MockUpdaterFactory(self.addons)
self.test_object = UpdateAllGUI(self.addons)
self.test_object.updater_factory = self.factory
def tearDown(self):
pass
def test_run(self):
self.factory.work_function = lambda: sleep(0.1)
self.test_object.run()
while self.test_object.is_running():
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 100)
self.test_object.dialog.accept()
def test_setup_dialog(self):
self.test_object._setup_dialog()
self.assertIsNotNone(
self.test_object.dialog.buttonBox.button(QtWidgets.QDialogButtonBox.Cancel)
)
self.assertEqual(self.test_object.dialog.tableWidget.rowCount(), 3)
def test_cancelling_installation(self):
self.factory.work_function = lambda: sleep(0.1)
self.test_object.run()
cancel_timer = QtCore.QTimer()
cancel_timer.timeout.connect(
self.test_object.dialog.buttonBox.button(
QtWidgets.QDialogButtonBox.Cancel
).click
)
cancel_timer.start(90)
while self.test_object.is_running():
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 10)
self.assertGreater(len(self.test_object.addons_with_update), 0)
def test_add_addon_to_table(self):
mock_addon = MockAddon("MockAddon")
self.test_object.dialog.tableWidget.clear()
self.test_object._add_addon_to_table(mock_addon)
self.assertEqual(self.test_object.dialog.tableWidget.rowCount(), 1)
def test_update_addon_status(self):
self.test_object._setup_dialog()
self.test_object._update_addon_status(0, AddonStatus.WAITING)
self.assertEqual(
self.test_object.dialog.tableWidget.item(0, 1).text(),
AddonStatus.WAITING.ui_string(),
)
self.test_object._update_addon_status(0, AddonStatus.INSTALLING)
self.assertEqual(
self.test_object.dialog.tableWidget.item(0, 1).text(),
AddonStatus.INSTALLING.ui_string(),
)
self.test_object._update_addon_status(0, AddonStatus.SUCCEEDED)
self.assertEqual(
self.test_object.dialog.tableWidget.item(0, 1).text(),
AddonStatus.SUCCEEDED.ui_string(),
)
self.test_object._update_addon_status(0, AddonStatus.FAILED)
self.assertEqual(
self.test_object.dialog.tableWidget.item(0, 1).text(),
AddonStatus.FAILED.ui_string(),
)
def test_process_next_update(self):
self.test_object._setup_dialog()
self.test_object._launch_active_installer = lambda: None
self.test_object._process_next_update()
self.assertEqual(
self.test_object.dialog.tableWidget.item(0, 1).text(),
AddonStatus.INSTALLING.ui_string(),
)
self.test_object._process_next_update()
self.assertEqual(
self.test_object.dialog.tableWidget.item(1, 1).text(),
AddonStatus.INSTALLING.ui_string(),
)
self.test_object._process_next_update()
self.assertEqual(
self.test_object.dialog.tableWidget.item(2, 1).text(),
AddonStatus.INSTALLING.ui_string(),
)
self.test_object._process_next_update()
def test_launch_active_installer(self):
self.test_object.active_installer = self.factory.get_updater(self.addons[0])
self.test_object._update_succeeded = lambda _: None
self.test_object._update_failed = lambda _: None
self.test_object.process_next_update = lambda: None
self.test_object._launch_active_installer()
# The above call does not block, so spin until it has completed (basically instantly in testing)
while self.test_object.worker_thread.isRunning():
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 100)
self.test_object.dialog.accept()
def test_update_succeeded(self):
self.test_object._setup_dialog()
self.test_object._update_succeeded(self.addons[0])
self.assertEqual(
self.test_object.dialog.tableWidget.item(0, 1).text(),
AddonStatus.SUCCEEDED.ui_string(),
)
def test_update_failed(self):
self.test_object._setup_dialog()
self.test_object._update_failed(self.addons[0])
self.assertEqual(
self.test_object.dialog.tableWidget.item(0, 1).text(),
AddonStatus.FAILED.ui_string(),
)
def test_update_finished(self):
self.test_object._setup_dialog()
call_interceptor = CallInterceptor()
self.test_object.worker_thread = QtCore.QThread()
self.test_object.worker_thread.start()
self.test_object._process_next_update = call_interceptor.intercept
self.test_object.active_installer = self.factory.get_updater(self.addons[0])
self.test_object._update_finished()
self.assertFalse(self.test_object.worker_thread.isRunning())
self.test_object.worker_thread.terminate()
self.assertTrue(call_interceptor.called)
self.test_object.worker_thread.wait()
def test_finalize(self):
self.test_object._setup_dialog()
self.test_object.worker_thread = QtCore.QThread()
self.test_object.worker_thread.start()
self.test_object._finalize()
self.assertFalse(self.test_object.worker_thread.isRunning())
self.test_object.worker_thread.terminate()
self.test_object.worker_thread.wait()
self.assertFalse(self.test_object.running)
self.assertIsNotNone(
self.test_object.dialog.buttonBox.button(QtWidgets.QDialogButtonBox.Close)
)
self.assertIsNone(
self.test_object.dialog.buttonBox.button(QtWidgets.QDialogButtonBox.Cancel)
)
def test_is_running(self):
self.assertFalse(self.test_object.is_running())
self.test_object.run()
self.assertTrue(self.test_object.is_running())
while self.test_object.is_running():
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 100)
self.test_object.dialog.accept()

View File

@@ -1,211 +0,0 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2022 FreeCAD Project Association *
# * *
# * This file is part of the FreeCAD CAx development system. *
# * *
# * This library 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. *
# * *
# * This library 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 this library; if not, write to the Free Software *
# * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA *
# * 02110-1301 USA *
# * *
# ***************************************************************************
import json
import os
import shutil
import stat
import tempfile
import unittest
import FreeCAD
from addonmanager_git import initialize_git
from PySide2 import QtCore
import NetworkManager
from Addon import Addon
from addonmanager_workers_startup import (
CreateAddonListWorker,
UpdateChecker,
)
from addonmanager_workers_installation import InstallWorkbenchWorker
class TestWorkersInstallation(unittest.TestCase):
MODULE = "test_workers_installation" # file name without extension
addon_list = (
[]
) # Cache at the class level so only the first test has to download it
def setUp(self):
"""Set up the test"""
self.test_dir = os.path.join(
FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data"
)
self.saved_mod_directory = Addon.mod_directory
self.saved_cache_directory = Addon.cache_directory
Addon.mod_directory = os.path.join(
tempfile.gettempdir(), "FreeCADTesting", "Mod"
)
Addon.cache_directory = os.path.join(
tempfile.gettempdir(), "FreeCADTesting", "Cache"
)
os.makedirs(Addon.mod_directory, mode=0o777, exist_ok=True)
os.makedirs(Addon.cache_directory, mode=0o777, exist_ok=True)
url = "https://api.github.com/zen"
NetworkManager.InitializeNetworkManager()
result = NetworkManager.AM_NETWORK_MANAGER.blocking_get(url)
if result is None:
self.skipTest("No active internet connection detected")
self.macro_counter = 0
self.workbench_counter = 0
self.prefpack_counter = 0
self.addon_from_cache_counter = 0
self.macro_from_cache_counter = 0
self.package_cache = {}
self.macro_cache = []
self.package_cache_filename = os.path.join(
Addon.cache_directory, "packages.json"
)
self.macro_cache_filename = os.path.join(Addon.cache_directory, "macros.json")
if not TestWorkersInstallation.addon_list:
self._create_addon_list()
# Workbench: use the FreeCAD-Help workbench for testing purposes
self.help_addon = None
for addon in self.addon_list:
if addon.name == "Help":
self.help_addon = addon
break
if not self.help_addon:
print("Unable to locate the FreeCAD-Help addon to test with")
self.skipTest("No active internet connection detected")
# Store the user's preference for whether git is enabled or disabled
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
self.saved_git_disabled_status = pref.GetBool("disableGit", False)
def tearDown(self):
mod_dir = os.path.join(tempfile.gettempdir(), "FreeCADTesting", "Mod")
if os.path.exists(mod_dir):
self._rmdir(mod_dir)
macro_dir = os.path.join(tempfile.gettempdir(), "FreeCADTesting", "Mod")
if os.path.exists(macro_dir):
self._rmdir(macro_dir)
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
pref.SetBool("disableGit", self.saved_git_disabled_status)
def test_workbench_installation(self):
addon_location = os.path.join(
tempfile.gettempdir(), "FreeCADTesting", "Mod", self.help_addon.name
)
worker = InstallWorkbenchWorker(self.help_addon, addon_location)
worker.run() # Synchronous call, blocks until complete
self.assertTrue(os.path.exists(addon_location))
self.assertTrue(os.path.exists(os.path.join(addon_location, "package.xml")))
def test_workbench_installation_git_disabled(self):
"""If the testing user has git enabled, also test the addon manager with git disabled"""
if self.saved_git_disabled_status:
self.skipTest("Git is disabled, this test is redundant")
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
pref.SetBool("disableGit", True)
self.test_workbench_installation()
pref.SetBool("disableGit", False)
def test_workbench_update_checker(self):
git_manager = initialize_git()
if not git_manager:
return
# Workbench: use the FreeCAD-Help workbench for testing purposes
help_addon = None
for addon in self.addon_list:
if addon.name == "Help":
help_addon = addon
break
if not help_addon:
print("Unable to locate the FreeCAD-Help addon to test with")
return
addon_location = os.path.join(
tempfile.gettempdir(), "FreeCADTesting", "Mod", self.help_addon.name
)
worker = InstallWorkbenchWorker(addon, addon_location)
worker.run() # Synchronous call, blocks until complete
self.assertEqual(help_addon.status(), Addon.Status.PENDING_RESTART)
# Back up one revision
git_manager.reset(addon_location, ["--hard", "HEAD~1"])
# At this point the addon should be "out of date", checked out to one revision behind
# the most recent.
worker = UpdateChecker()
worker.override_mod_directory(
os.path.join(tempfile.gettempdir(), "FreeCADTesting", "Mod")
)
worker.check_workbench(help_addon) # Synchronous call
self.assertEqual(help_addon.status(), Addon.Status.UPDATE_AVAILABLE)
# Now try to "update" it (which is really done via the install worker)
worker = InstallWorkbenchWorker(addon, addon_location)
worker.run() # Synchronous call, blocks until complete
self.assertEqual(help_addon.status(), Addon.Status.PENDING_RESTART)
def _rmdir(self, path):
try:
shutil.rmtree(path, onerror=self._remove_readonly)
except Exception as e:
print(e)
def _remove_readonly(self, func, path, _) -> None:
"""Remove a read-only file."""
os.chmod(path, stat.S_IWRITE)
func(path)
def _create_addon_list(self):
"""Create the list of addons"""
worker = CreateAddonListWorker()
worker.addon_repo.connect(self._addon_added)
worker.start()
while worker.isRunning():
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents)
def _addon_added(self, addon: Addon):
"""Callback for adding an Addon: tracks the list, and counts the various types"""
print(f"Addon added: {addon.name}")
TestWorkersInstallation.addon_list.append(addon)
if addon.contains_workbench():
self.workbench_counter += 1
if addon.contains_macro():
self.macro_counter += 1
if addon.contains_preference_pack():
self.prefpack_counter += 1

View File

@@ -27,8 +27,6 @@ import unittest
import os
import tempfile
from addonmanager_git import initialize_git
import FreeCAD
from PySide2 import QtCore
@@ -39,11 +37,6 @@ from addonmanager_workers_startup import (
CreateAddonListWorker,
LoadPackagesFromCacheWorker,
LoadMacrosFromCacheWorker,
CheckSingleUpdateWorker,
)
from addonmanager_workers_installation import (
InstallWorkbenchWorker,
)
@@ -131,7 +124,6 @@ class TestWorkersStartup(unittest.TestCase):
f.write(json.dumps(self.macro_cache, indent=" "))
original_macro_counter = self.macro_counter
original_workbench_counter = self.workbench_counter
original_addon_list = self.addon_list.copy()
self.macro_counter = 0
self.workbench_counter = 0