AddonManager: Refactor uninstaller GUI

Offload uninstaller GUI into its own class, add tests for that class, and do
some additional minor cleanup of AddonManager.py.
This commit is contained in:
Chris Hennes
2022-12-18 20:45:09 -06:00
parent 2adbcc9199
commit 9f64beb73f
15 changed files with 516 additions and 240 deletions

View File

@@ -25,15 +25,25 @@
import os
import shutil
import FreeCAD
class MockAddon:
"""Minimal Addon class"""
def __init__(self):
self.name = "TestAddon"
self.url = "https://github.com/FreeCAD/FreeCAD-addons"
self.branch = "master"
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"
self.macro = None
self.status = None
def set_status(self, status):
self.status = status
class MockMacro:

View File

@@ -33,7 +33,7 @@ from addonmanager_dependency_installer import DependencyInstaller
class CompleteProcessMock(subprocess.CompletedProcess):
def __init__(self):
super().__init__(["fake_arg"], 0)
self.stdout = "Mock suprocess call stdout result"
self.stdout = "Mock subprocess call stdout result"
class SubprocessMock:

View File

@@ -115,7 +115,7 @@ class TestAddonInstaller(unittest.TestCase):
with tempfile.TemporaryDirectory() as temp_dir:
installer.installation_path = temp_dir
installer._update_metadata()
addon_dir = os.path.join(temp_dir, self.mock_addon.name)
addon_dir = os.path.join(temp_dir, self.real_addon.name)
os.mkdir(addon_dir)
shutil.copy(
os.path.join(self.test_data_dir, "good_package.xml"),
@@ -125,7 +125,7 @@ class TestAddonInstaller(unittest.TestCase):
installer._update_metadata()
self.assertEqual(self.real_addon.installed_version, good_metadata.Version)
def test_finalize_zip_installation(self):
def test_finalize_zip_installation_non_github(self):
"""Ensure that zipfiles are correctly extracted."""
with tempfile.TemporaryDirectory() as temp_dir:
test_simple_repo = os.path.join(self.test_data_dir, "test_simple_repo.zip")
@@ -140,12 +140,15 @@ class TestAddonInstaller(unittest.TestCase):
os.path.isfile(expected_location), "Non-GitHub zip extraction failed"
)
def test_finalize_zip_installation_github(self):
with tempfile.TemporaryDirectory() as temp_dir:
test_github_style_repo = os.path.join(
self.test_data_dir, "test_github_style_repo.zip"
)
installer = AddonInstaller(self.mock_addon, [])
installer.installation_path = temp_dir
self.mock_addon.url = test_github_style_repo
self.mock_addon.branch = "master"
installer._finalize_zip_installation(test_github_style_repo)
expected_location = os.path.join(temp_dir, self.mock_addon.name, "README")
self.assertTrue(

View File

@@ -184,19 +184,22 @@ static char * blarg_xpm[] = {
return m
def test_fetch_raw_code_no_data(self):
class MockNetworkManagerNoData():
class MockNetworkManagerNoData:
def __init__(self):
self.fetched_url = None
def blocking_get(self, url):
self.fetched_url = url
return None
nmNoData = MockNetworkManagerNoData()
m = Macro("Unit Test Macro")
Macro.network_manager = nmNoData
returned_data = m._fetch_raw_code("rawcodeurl <a href=\"https://fake_url.com\">Totally fake</a>")
returned_data = m._fetch_raw_code(
'rawcodeurl <a href="https://fake_url.com">Totally fake</a>'
)
self.assertIsNone(returned_data)
self.assertEqual(nmNoData.fetched_url,"https://fake_url.com")
self.assertEqual(nmNoData.fetched_url, "https://fake_url.com")
nmNoData.fetched_url = None
returned_data = m._fetch_raw_code("Fake pagedata with no URL at all.")
@@ -206,13 +209,14 @@ static char * blarg_xpm[] = {
Macro.network_manager = None
def test_fetch_raw_code_with_data(self):
class MockNetworkManagerWithData():
class MockNetworkManagerWithData:
class MockQByteArray:
def data(self):
return "Data returned to _fetch_raw_code".encode("utf-8")
def __init__(self):
self.fetched_url = None
def blocking_get(self, url):
self.fetched_url = url
return MockNetworkManagerWithData.MockQByteArray()
@@ -220,7 +224,9 @@ static char * blarg_xpm[] = {
nmWithData = MockNetworkManagerWithData()
m = Macro("Unit Test Macro")
Macro.network_manager = nmWithData
returned_data = m._fetch_raw_code("rawcodeurl <a href=\"https://fake_url.com\">Totally fake</a>")
self.assertEqual(returned_data,"Data returned to _fetch_raw_code")
returned_data = m._fetch_raw_code(
'rawcodeurl <a href="https://fake_url.com">Totally fake</a>'
)
self.assertEqual(returned_data, "Data returned to _fetch_raw_code")
Macro.network_manager = None

View File

@@ -449,10 +449,15 @@ class TestMacroUninstaller(unittest.TestCase):
self.assertTrue(os.path.exists(f))
os.chmod(f, S_IREAD | S_IRGRP | S_IROTH)
self.test_object.run()
os.chmod(f, S_IWUSR | S_IREAD)
self.assertIn("failure", self.signals_caught)
self.assertNotIn("success", self.signals_caught)
if os.path.exists(f):
os.chmod(f, S_IWUSR | S_IREAD)
self.assertNotIn("success", self.signals_caught)
self.assertIn("failure", self.signals_caught)
else:
# In some cases we managed to delete it anyway:
self.assertIn("success", self.signals_caught)
self.assertNotIn("failure", self.signals_caught)
self.assertIn("finished", self.signals_caught)
def test_cleanup_directories_multiple_empty(self):

View File

@@ -20,3 +20,83 @@
# * *
# ***************************************************************************
from PySide import QtCore, QtWidgets
class DialogWatcher(QtCore.QObject):
"""Examine the running GUI and look for a modal dialog with a given title, containing a button
with a given label. Click that button, which is expected to close the dialog. Generally run on
a one-shot QTimer to allow the dialog time to open up."""
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. The callable is passed
the widget we found and can do whatever it wants to it. Whatever it does should eventually
close the dialog, however."""
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
):
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 FakeWorker:
def __init__(self):
self.called = False
self.should_continue = True
def work(self):
while self.should_continue:
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 100)
def stop(self):
self.should_continue = False
class MockThread:
def wait(self):
pass
def isRunning(self):
return False

View File

@@ -19,6 +19,7 @@
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
import os
import tempfile
import unittest
@@ -28,78 +29,18 @@ from PySide import QtCore, QtWidgets
from addonmanager_installer_gui import AddonInstallerGUI, MacroInstallerGUI
from AddonManagerTest.gui.gui_mocks import DialogWatcher, DialogInteractor
from AddonManagerTest.app.mocks import MockAddon
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.addon_to_install = MockAddon()
self.installer_gui = AddonInstallerGUI(self.addon_to_install)
self.finalized_thread = False

View File

@@ -0,0 +1,148 @@
# ***************************************************************************
# * *
# * 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 functools
import unittest
from PySide import QtCore
import FreeCAD
from AddonManagerTest.gui.gui_mocks import (
DialogWatcher,
DialogInteractor,
FakeWorker,
MockThread,
)
from AddonManagerTest.app.mocks import MockAddon
from addonmanager_uninstaller_gui import AddonUninstallerGUI
translate = FreeCAD.Qt.translate
class TestUninstallerGUI(unittest.TestCase):
MODULE = "test_uninstaller_gui" # file name without extension
def setUp(self):
self.addon_to_remove = MockAddon()
self.uninstaller_gui = AddonUninstallerGUI(self.addon_to_remove)
self.finalized_thread = False
self.signals_caught = []
def tearDown(self):
pass
def catch_signal(self, signal_name, *_):
self.signals_caught.append(signal_name)
def test_confirmation_dialog_yes(self):
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Confirm remove"),
translate("AddonsInstaller", "Yes"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
answer = self.uninstaller_gui._confirm_uninstallation()
self.assertTrue(
dialog_watcher.dialog_found, "Failed to find the expected dialog box"
)
self.assertTrue(answer, "Expected a 'Yes' click to return True, but got False")
def test_confirmation_dialog_cancel(self):
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Confirm remove"),
translate("AddonsInstaller", "Cancel"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
answer = self.uninstaller_gui._confirm_uninstallation()
self.assertTrue(
dialog_watcher.dialog_found, "Failed to find the expected dialog box"
)
self.assertFalse(
answer, "Expected a 'Cancel' click to return False, but got True"
)
def test_progress_dialog(self):
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Removing Addon"),
translate("AddonsInstaller", "Cancel"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
self.uninstaller_gui._show_progress_dialog()
# That call isn't modal, so spin our own event loop:
while self.uninstaller_gui.progress_dialog.isVisible():
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 100)
self.assertTrue(
dialog_watcher.dialog_found, "Failed to find the expected dialog box"
)
def test_timer_launches_progress_dialog(self):
worker = FakeWorker()
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Removing Addon"),
translate("AddonsInstaller", "Cancel"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
QtCore.QTimer.singleShot(20, worker.stop)
self.uninstaller_gui._confirm_uninstallation = lambda: True
self.uninstaller_gui._run_uninstaller = worker.work
self.uninstaller_gui._finalize = lambda: None
self.uninstaller_gui.dialog_timer.setInterval(
1
) # To speed up the test, only wait 1ms
self.uninstaller_gui.run() # Blocks once it hits the fake worker
self.assertTrue(
dialog_watcher.dialog_found, "Failed to find the expected dialog box"
)
def test_success_dialog(self):
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Uninstall complete"),
translate("AddonsInstaller", "OK"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
self.uninstaller_gui._succeeded(self.addon_to_remove)
self.assertTrue(
dialog_watcher.dialog_found, "Failed to find the expected dialog box"
)
def test_failure_dialog(self):
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Uninstall failed"),
translate("AddonsInstaller", "OK"),
)
QtCore.QTimer.singleShot(10, dialog_watcher.run)
self.uninstaller_gui._failed(
self.addon_to_remove, "Some failure message\nAnother failure message"
)
self.assertTrue(
dialog_watcher.dialog_found, "Failed to find the expected dialog box"
)
def test_finalize(self):
self.uninstaller_gui.finished.connect(
functools.partial(self.catch_signal, "finished")
)
self.uninstaller_gui.worker_thread = MockThread()
self.uninstaller_gui._finalize()
self.assertIn("finished", self.signals_caught)