eric7/Unittest/UnittestWidget.py

branch
unittest
changeset 9059
e7fd342f8bfc
child 9062
7f27bf3b50c3
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric7/Unittest/UnittestWidget.py	Thu May 12 08:59:13 2022 +0200
@@ -0,0 +1,680 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing a widget to orchestrate unit test execution.
+"""
+
+import enum
+import os
+
+from PyQt6.QtCore import pyqtSlot, Qt, QEvent, QCoreApplication
+from PyQt6.QtWidgets import (
+    QAbstractButton, QComboBox, QDialogButtonBox, QWidget
+)
+
+from EricWidgets import EricMessageBox
+from EricWidgets.EricApplication import ericApp
+from EricWidgets.EricMainWindow import EricMainWindow
+from EricWidgets.EricPathPicker import EricPathPickerModes
+
+from .Ui_UnittestWidget import Ui_UnittestWidget
+
+from .UTTestResultsTree import TestResultsModel, TestResultsTreeView
+from .Interfaces import Frameworks
+from .Interfaces.UTExecutorBase import UTTestConfig, UTTestResult
+from .Interfaces.UTFrameworkRegistry import UTFrameworkRegistry
+
+import Preferences
+import UI.PixmapCache
+
+from Globals import (
+    recentNameUnittestDiscoverHistory, recentNameUnittestFileHistory,
+    recentNameUnittestTestnameHistory, recentNameUnittestFramework,
+    recentNameUnittestEnvironment
+)
+
+
+class UnittestWidgetModes(enum.Enum):
+    """
+    Class defining the various modes of the unittest widget.
+    """
+    IDLE = 0            # idle, no test were run yet
+    RUNNING = 1         # test run being performed
+    STOPPED = 2         # test run finished
+
+
+class UnittestWidget(QWidget, Ui_UnittestWidget):
+    """
+    Class implementing a widget to orchestrate unit test execution.
+    """
+    def __init__(self, testfile=None, parent=None):
+        """
+        Constructor
+        
+        @param testfile file name of the test to load
+        @type str
+        @param parent reference to the parent widget (defaults to None)
+        @type QWidget (optional)
+        """
+        super().__init__(parent)
+        self.setupUi(self)
+        
+        self.__resultsModel = TestResultsModel(self)
+        self.__resultsTree = TestResultsTreeView(self)
+        self.__resultsTree.setModel(self.__resultsModel)
+        self.resultsGroupBox.layout().addWidget(self.__resultsTree)
+        
+        self.versionsButton.setIcon(
+            UI.PixmapCache.getIcon("info"))
+        self.clearHistoriesButton.setIcon(
+            UI.PixmapCache.getIcon("clearPrivateData"))
+        
+        self.testsuitePicker.setMode(EricPathPickerModes.OPEN_FILE_MODE)
+        self.testsuitePicker.setInsertPolicy(
+            QComboBox.InsertPolicy.InsertAtTop)
+        self.testsuitePicker.setSizeAdjustPolicy(
+            QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
+        
+        self.discoveryPicker.setMode(EricPathPickerModes.DIRECTORY_MODE)
+        self.discoveryPicker.setInsertPolicy(
+            QComboBox.InsertPolicy.InsertAtTop)
+        self.discoveryPicker.setSizeAdjustPolicy(
+            QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
+        
+        self.testComboBox.lineEdit().setClearButtonEnabled(True)
+        
+        # create some more dialog buttons for orchestration
+        self.__startButton = self.buttonBox.addButton(
+            self.tr("Start"), QDialogButtonBox.ButtonRole.ActionRole)
+        
+        self.__startButton.setToolTip(self.tr(
+            "Start the selected testsuite"))
+        self.__startButton.setWhatsThis(self.tr(
+            """<b>Start Test</b>"""
+            """<p>This button starts the selected testsuite.</p>"""))
+        
+        # TODO: implement "Rerun Failed"
+##        self.__startFailedButton = self.buttonBox.addButton(
+##            self.tr("Rerun Failed"), QDialogButtonBox.ButtonRole.ActionRole)
+##        self.__startFailedButton.setToolTip(
+##            self.tr("Reruns failed tests of the selected testsuite"))
+##        self.__startFailedButton.setWhatsThis(self.tr(
+##            """<b>Rerun Failed</b>"""
+##            """<p>This button reruns all failed tests of the selected"""
+##            """ testsuite.</p>"""))
+##        
+        self.__stopButton = self.buttonBox.addButton(
+            self.tr("Stop"), QDialogButtonBox.ButtonRole.ActionRole)
+        self.__stopButton.setToolTip(self.tr("Stop the running unittest"))
+        self.__stopButton.setWhatsThis(self.tr(
+            """<b>Stop Test</b>"""
+            """<p>This button stops a running unittest.</p>"""))
+        
+        self.__stopButton.setEnabled(False)
+        self.__startButton.setDefault(True)
+        self.__startButton.setEnabled(False)
+##        self.__startFailedButton.setEnabled(False)
+        
+        self.setWindowFlags(
+            self.windowFlags() |
+            Qt.WindowType.WindowContextHelpButtonHint
+        )
+        self.setWindowIcon(UI.PixmapCache.getIcon("eric"))
+        self.setWindowTitle(self.tr("Unittest"))
+        
+        from VirtualEnv.VirtualenvManager import VirtualenvManager
+        self.__venvManager = VirtualenvManager(self)
+        self.__venvManager.virtualEnvironmentAdded.connect(
+            self.__populateVenvComboBox)
+        self.__venvManager.virtualEnvironmentRemoved.connect(
+            self.__populateVenvComboBox)
+        self.__venvManager.virtualEnvironmentChanged.connect(
+            self.__populateVenvComboBox)
+        
+        # TODO: implement project mode
+        self.__forProject = False
+        
+        self.__discoverHistory = []
+        self.__fileHistory = []
+        self.__testNameHistory = []
+        self.__recentFramework = ""
+        self.__recentEnvironment = ""
+        
+        self.__failedTests = set()
+        
+        self.__editors = []
+        self.__testExecutor = None
+        
+        # connect some signals
+        self.frameworkComboBox.currentIndexChanged.connect(
+            self.__updateButtonBoxButtons)
+        self.discoverCheckBox.toggled.connect(
+            self.__updateButtonBoxButtons)
+        self.discoveryPicker.editTextChanged.connect(
+            self.__updateButtonBoxButtons)
+        self.testsuitePicker.editTextChanged.connect(
+            self.__updateButtonBoxButtons)
+        
+        self.__frameworkRegistry = UTFrameworkRegistry()
+        for framework in Frameworks:
+            self.__frameworkRegistry.register(framework)
+        
+        self.__setIdleMode()
+        
+        self.__loadRecent()
+        self.__populateVenvComboBox()
+        
+        if self.__forProject:
+            project = ericApp().getObject("Project")
+            if project.isOpen():
+                self.__insertDiscovery(project.getProjectPath())
+            else:
+                self.__insertDiscovery("")
+        else:
+            self.__insertDiscovery("")
+        self.__insertProg(testfile)
+        self.__insertTestName("")
+        
+        self.clearHistoriesButton.clicked.connect(self.clearRecent)
+        
+        self.tabWidget.setCurrentIndex(0)
+    
+    def __populateVenvComboBox(self):
+        """
+        Private method to (re-)populate the virtual environments selector.
+        """
+        currentText = self.venvComboBox.currentText()
+        if not currentText:
+            currentText = self.__recentEnvironment
+        
+        self.venvComboBox.clear()
+        self.venvComboBox.addItem("")
+        self.venvComboBox.addItems(
+            sorted(self.__venvManager.getVirtualenvNames()))
+        index = self.venvComboBox.findText(currentText)
+        if index < 0:
+            index = 0
+        self.venvComboBox.setCurrentIndex(index)
+    
+    def __populateTestFrameworkComboBox(self):
+        """
+        Private method to (re-)populate the test framework selector.
+        """
+        currentText = self.frameworkComboBox.currentText()
+        if not currentText:
+            currentText = self.__recentFramework
+        
+        self.frameworkComboBox.clear()
+        
+        if bool(self.venvComboBox.currentText()):
+            interpreter = self.__venvManager.getVirtualenvInterpreter(
+                self.venvComboBox.currentText())
+            self.frameworkComboBox.addItem("")
+            for index, (name, executor) in enumerate(
+                sorted(self.__frameworkRegistry.getFrameworks().items()),
+                start=1
+            ):
+                isInstalled = executor.isInstalled(interpreter)
+                entry = (
+                    name
+                    if isInstalled else
+                    self.tr("{0} (not available)").format(name)
+                )
+                self.frameworkComboBox.addItem(entry)
+                self.frameworkComboBox.model().item(index).setEnabled(
+                    isInstalled)
+            
+            self.frameworkComboBox.setCurrentText(self.__recentFramework)
+    
+    @pyqtSlot(str)
+    def __insertHistory(self, widget, history, item):
+        """
+        Private slot to insert an item into a history object.
+        
+        @param widget reference to the widget
+        @type QComboBox or EricComboPathPicker
+        @param history array containing the history
+        @type list of str
+        @param item item to be inserted
+        @type str
+        """
+        current = widget.currentText()
+        
+        # prepend the given directory to the discovery picker
+        if item is None:
+            item = ""
+        if item in history:
+            history.remove(item)
+        history.insert(0, item)
+        widget.clear()
+        widget.addItems(history)
+        
+        if current:
+            widget.setText(current)
+    
+    @pyqtSlot(str)
+    def __insertDiscovery(self, start):
+        """
+        Private slot to insert the discovery start directory into the
+        discoveryPicker object.
+        
+        @param start start directory name to be inserted
+        @type str
+        """
+        self.__insertHistory(self.discoveryPicker, self.__discoverHistory,
+                             start)
+    
+    @pyqtSlot(str)
+    def __insertProg(self, prog):
+        """
+        Private slot to insert a test file name into the testsuitePicker
+        object.
+        
+        @param prog test file name to be inserted
+        @type str
+        """
+        self.__insertHistory(self.testsuitePicker, self.__fileHistory,
+                             prog)
+    
+    @pyqtSlot(str)
+    def __insertTestName(self, testName):
+        """
+        Private slot to insert a test name into the testComboBox object.
+        
+        @param testName name of the test to be inserted
+        @type str
+        """
+        self.__insertHistory(self.testComboBox, self.__testNameHistory,
+                             testName)
+    
+    def __loadRecent(self):
+        """
+        Private method to load the most recently used lists.
+        """
+        Preferences.Prefs.rsettings.sync()
+        
+        # 1. recently selected test framework and virtual environment
+        self.__recentEnvironment = Preferences.Prefs.rsettings.value(
+            recentNameUnittestEnvironment, "")
+        self.__recentFramework = Preferences.Prefs.rsettings.value(
+            recentNameUnittestFramework, "")
+        
+        # 2. discovery history
+        self.__discoverHistory = []
+        rs = Preferences.Prefs.rsettings.value(
+            recentNameUnittestDiscoverHistory)
+        if rs is not None:
+            recent = [f for f in Preferences.toList(rs) if os.path.exists(f)]
+            self.__discoverHistory = recent[
+                :Preferences.getDebugger("RecentNumber")]
+        
+        # 3. test file history
+        self.__fileHistory = []
+        rs = Preferences.Prefs.rsettings.value(
+            recentNameUnittestFileHistory)
+        if rs is not None:
+            recent = [f for f in Preferences.toList(rs) if os.path.exists(f)]
+            self.__fileHistory = recent[
+                :Preferences.getDebugger("RecentNumber")]
+        
+        # 4. test name history
+        self.__testNameHistory = []
+        rs = Preferences.Prefs.rsettings.value(
+            recentNameUnittestTestnameHistory)
+        if rs is not None:
+            recent = [n for n in Preferences.toList(rs) if n]
+            self.__testNameHistory = recent[
+                :Preferences.getDebugger("RecentNumber")]
+    
+    def __saveRecent(self):
+        """
+        Private method to save the most recently used lists.
+        """
+        Preferences.Prefs.rsettings.setValue(
+            recentNameUnittestEnvironment, self.__recentEnvironment)
+        Preferences.Prefs.rsettings.setValue(
+            recentNameUnittestFramework, self.__recentFramework)
+        Preferences.Prefs.rsettings.setValue(
+            recentNameUnittestDiscoverHistory, self.__discoverHistory)
+        Preferences.Prefs.rsettings.setValue(
+            recentNameUnittestFileHistory, self.__fileHistory)
+        Preferences.Prefs.rsettings.setValue(
+            recentNameUnittestTestnameHistory, self.__testNameHistory)
+        
+        Preferences.Prefs.rsettings.sync()
+    
+    @pyqtSlot()
+    def clearRecent(self):
+        """
+        Public slot to clear the recently used lists.
+        """
+        # clear histories
+        self.__discoverHistory = []
+        self.__fileHistory = []
+        self.__testNameHistory = []
+        
+        # clear widgets with histories
+        self.discoveryPicker.clear()
+        self.testsuitePicker.clear()
+        self.testComboBox.clear()
+        
+        # sync histories
+        self.__saveRecent()
+    
+    def __updateButtonBoxButtons(self):
+        """
+        Private method to update the state of the buttons of the button box.
+        """
+        failedAvailable = bool(self.__failedTests)
+        
+        # Start button
+        if self.__mode in (
+            UnittestWidgetModes.IDLE, UnittestWidgetModes.STOPPED
+        ):
+            self.__startButton.setEnabled(
+                bool(self.venvComboBox.currentText()) and
+                bool(self.frameworkComboBox.currentText()) and
+                (
+                    (self.discoverCheckBox.isChecked() and
+                     bool(self.discoveryPicker.currentText())) or
+                    bool(self.testsuitePicker.currentText())
+                )
+            )
+            self.__startButton.setDefault(
+                self.__mode == UnittestWidgetModes.IDLE or
+                not failedAvailable
+            )
+        else:
+            self.__startButton.setEnabled(False)
+            self.__startButton.setDefault(False)
+        
+        # Start Failed button
+        # TODO: not implemented yet
+        
+        # Stop button
+        self.__stopButton.setEnabled(
+            self.__mode == UnittestWidgetModes.RUNNING)
+        self.__stopButton.setDefault(
+            self.__mode == UnittestWidgetModes.RUNNING)
+    
+    def __setIdleMode(self):
+        """
+        Private method to switch the widget to idle mode.
+        """
+        self.__mode = UnittestWidgetModes.IDLE
+        self.__updateButtonBoxButtons()
+    
+    def __setRunningMode(self):
+        """
+        Private method to switch the widget to running mode.
+        """
+        # TODO: not implemented yet
+        pass
+    
+    def __setStoppedMode(self):
+        """
+        Private method to switch the widget to stopped mode.
+        """
+        # TODO: not implemented yet
+        pass
+    
+    @pyqtSlot(QAbstractButton)
+    def on_buttonBox_clicked(self, button):
+        """
+        Private slot called by a button of the button box clicked.
+        
+        @param button button that was clicked
+        @type QAbstractButton
+        """
+##        if button == self.discoverButton:
+##            self.__discover()
+##            self.__saveRecent()
+##        elif button == self.__startButton:
+        if button == self.__startButton:
+            self.startTests()
+            self.__saveRecent()
+        elif button == self.__stopButton:
+            self.__stopTests()
+#        elif button == self.__startFailedButton:
+#            self.startTests(failedOnly=True)
+    
+    @pyqtSlot(int)
+    def on_venvComboBox_currentIndexChanged(self, index):
+        """
+        Private slot handling the selection of a virtual environment.
+        
+        @param index index of the selected environment
+        @type int
+        """
+        self.__populateTestFrameworkComboBox()
+        self.__updateButtonBoxButtons()
+        
+        self.versionsButton.setEnabled(bool(self.venvComboBox.currentText()))
+    
+    @pyqtSlot()
+    def on_versionsButton_clicked(self):
+        """
+        Private slot to show the versions of available plugins.
+        """
+        venvName = self.venvComboBox.currentText()
+        if venvName:
+            headerText = self.tr("<h3>Versions of Frameworks and their"
+                                 " Plugins</h3>")
+            versionsText = ""
+            interpreter = self.__venvManager.getVirtualenvInterpreter(venvName)
+            for framework in sorted(
+                self.__frameworkRegistry.getFrameworks().keys()
+            ):
+                executor = self.__frameworkRegistry.createExecutor(
+                    framework, self)
+                versions = executor.getVersions(interpreter)
+                if versions:
+                    txt = "<p><strong>{0} {1}</strong>".format(
+                        versions["name"], versions["version"])
+                    
+                    if versions["plugins"]:
+                        txt += "<table>"
+                        for pluginVersion in versions["plugins"]:
+                            txt += self.tr(
+                                "<tr><td>{0}</td><td>{1}</td></tr>"
+                            ).format(
+                                pluginVersion["name"], pluginVersion["version"]
+                            )
+                        txt += "</table>"
+                    txt += "</p>"
+                    
+                    versionsText += txt
+            
+            if not versionsText:
+                versionsText = self.tr("No version information available.")
+            
+            EricMessageBox.information(
+                self,
+                self.tr("Versions"),
+                headerText + versionsText
+            )
+    
+    @pyqtSlot()
+    def startTests(self, failedOnly=False):
+        """
+        Public slot to start the test run.
+        
+        @param failedOnly flag indicating to run only failed tests
+        @type bool
+        """
+        if self.__mode == UnittestWidgetModes.RUNNING:
+            return
+        
+        self.__recentEnvironment = self.venvComboBox.currentText()
+        self.__recentFramework = self.frameworkComboBox.currentText()
+        
+        discover = self.discoverCheckBox.isChecked()
+        if discover:
+            discoveryStart = self.discoveryPicker.currentText()
+            testFileName = ""
+            testName = ""
+            
+            if discoveryStart:
+                self.__insertDiscovery(discoveryStart)
+        else:
+            discoveryStart = ""
+            testFileName = self.testsuitePicker.currentText()
+            if testFileName:
+                self.__insertProg(testFileName)
+            testName = self.testComboBox.currentText()
+            if testName:
+                self.insertTestName(testName)
+            if testFileName and not testName:
+                testName = "suite"
+        
+        interpreter = self.__venvManager.getVirtualenvInterpreter(
+            self.__recentEnvironment)
+        config = UTTestConfig(
+            interpreter=interpreter,
+            discover=self.discoverCheckBox.isChecked(),
+            discoveryStart=discoveryStart,
+            testFilename=testFileName,
+            testName=testName,
+            failFast=self.failfastCheckBox.isChecked(),
+            collectCoverage=self.coverageCheckBox.isChecked(),
+            eraseCoverage=self.coverageEraseCheckBox.isChecked(),
+        )
+        
+        self.__resultsModel.clear()
+        self.__testExecutor = self.__frameworkRegistry.createExecutor(
+            self.__recentFramework, self)
+        self.__testExecutor.collected.connect(self.__testCollected)
+        self.__testExecutor.collectError.connect(self.__testsCollectError)
+        self.__testExecutor.startTest.connect(self.__testsStarted)
+        self.__testExecutor.testResult.connect(self.__processTestResult)
+        self.__testExecutor.testFinished.connect(self.__testProcessFinished)
+        self.__testExecutor.stop.connect(self.__testsStopped)
+        self.__testExecutor.start(config, [])
+        
+        # TODO: not yet implemented
+        pass
+    
+    @pyqtSlot(list)
+    def __testCollected(self, testNames):
+        """
+        Private slot handling the 'collected' signal of the executor.
+        
+        @param testNames list of names of collected tests
+        @type list of str
+        """
+        # TODO: not implemented yet
+        pass
+    
+    @pyqtSlot(list)
+    def __testsCollectError(self, errors):
+        """
+        Private slot handling the 'collectError' signal of the executor.
+        
+        @param errors list of tuples containing the test name and a description
+            of the error
+        @type list of tuple of (str, str)
+        """
+        # TODO: not implemented yet
+        pass
+    
+    @pyqtSlot(list)
+    def __testsStarted(self, testNames):
+        """
+        Private slot handling the 'startTest' signal of the executor.
+        
+        @param testNames list of names of tests about to be run
+        @type list of str
+        """
+        # TODO: not implemented yet
+        pass
+    
+    @pyqtSlot(UTTestResult)
+    def __processTestResult(self, result):
+        """
+        Private slot to handle the receipt of a test result object.
+        
+        @param result test result object
+        @type UTTestResult
+        """
+        # TODO: not implemented yet
+        pass
+    
+    @pyqtSlot(list, str)
+    def __testProcessFinished(self, results, output):
+        """
+        Private slot to handle the 'testFinished' signal of the executor.
+        
+        @param results list of test result objects (if not sent via the
+            'testResult' signal
+        @type list of UTTestResult
+        @param output string containing the test process output (if any)
+        @type str
+        """
+        # TODO: not implemented yet
+        pass
+    
+    @pyqtSlot()
+    def __testsStopped(self):
+        """
+        Private slot to handle the 'stop' signal of the executor.
+        """
+        # TODO: not implemented yet
+        pass
+
+
+class UnittestWindow(EricMainWindow):
+    """
+    Main window class for the standalone dialog.
+    """
+    def __init__(self, testfile=None, parent=None):
+        """
+        Constructor
+        
+        @param testfile file name of the test script to open
+        @type str
+        @param parent reference to the parent widget
+        @type QWidget
+        """
+        super().__init__(parent)
+        self.__cw = UnittestWidget(testfile=testfile, parent=self)
+        self.__cw.installEventFilter(self)
+        size = self.__cw.size()
+        self.setCentralWidget(self.__cw)
+        self.resize(size)
+        
+        self.setStyle(Preferences.getUI("Style"),
+                      Preferences.getUI("StyleSheet"))
+        
+        self.__cw.buttonBox.accepted.connect(self.close)
+        self.__cw.buttonBox.rejected.connect(self.close)
+    
+    def eventFilter(self, obj, event):
+        """
+        Public method to filter events.
+        
+        @param obj reference to the object the event is meant for (QObject)
+        @param event reference to the event object (QEvent)
+        @return flag indicating, whether the event was handled (boolean)
+        """
+        if event.type() == QEvent.Type.Close:
+            QCoreApplication.exit(0)
+            return True
+        
+        return False
+
+
+def clearSavedHistories(self):
+    """
+    Function to clear the saved history lists.
+    """
+    Preferences.Prefs.rsettings.setValue(
+        recentNameUnittestDiscoverHistory, [])
+    Preferences.Prefs.rsettings.setValue(
+        recentNameUnittestFileHistory, [])
+    Preferences.Prefs.rsettings.setValue(
+        recentNameUnittestTestnameHistory, [])
+    
+    Preferences.Prefs.rsettings.sync()

eric ide

mercurial