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:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user