Implemented the basic functionality of the new unit test framework. unittest

Thu, 12 May 2022 08:59:13 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Thu, 12 May 2022 08:59:13 +0200
branch
unittest
changeset 9059
e7fd342f8bfc
parent 9057
ddc46e93ccc4
child 9060
eb17e1744940

Implemented the basic functionality of the new unit test framework.

eric7.epj file | annotate | diff | comparison | revisions
eric7/Globals/__init__.py file | annotate | diff | comparison | revisions
eric7/Unittest/Interfaces/PytestExecutor.py file | annotate | diff | comparison | revisions
eric7/Unittest/Interfaces/PytestRunner.py file | annotate | diff | comparison | revisions
eric7/Unittest/Interfaces/UTExecutorBase.py file | annotate | diff | comparison | revisions
eric7/Unittest/Interfaces/UTFrameworkRegistry.py file | annotate | diff | comparison | revisions
eric7/Unittest/Interfaces/UnittestExecutor.py file | annotate | diff | comparison | revisions
eric7/Unittest/Interfaces/UnittestRunner.py file | annotate | diff | comparison | revisions
eric7/Unittest/Interfaces/__init__.py file | annotate | diff | comparison | revisions
eric7/Unittest/UTTestResultsTree.py file | annotate | diff | comparison | revisions
eric7/Unittest/UnittestWidget.py file | annotate | diff | comparison | revisions
eric7/Unittest/UnittestWidget.ui file | annotate | diff | comparison | revisions
eric7/Unittest/__init__.py file | annotate | diff | comparison | revisions
eric7/eric7_unittest.py file | annotate | diff | comparison | revisions
--- a/eric7.epj	Sun May 08 19:58:27 2022 +0200
+++ b/eric7.epj	Thu May 12 08:59:13 2022 +0200
@@ -661,6 +661,7 @@
       "eric7/UI/SearchWidgetLine.ui",
       "eric7/UI/SymbolsWidget.ui",
       "eric7/UI/VersionsDialog.ui",
+      "eric7/Unittest/UnittestWidget.ui",
       "eric7/VCS/CommandOptionsDialog.ui",
       "eric7/VCS/RepositoryInfoDialog.ui",
       "eric7/ViewManager/BookmarkedFilesDialog.ui",
@@ -2009,6 +2010,16 @@
       "eric7/UI/__init__.py",
       "eric7/UI/data/__init__.py",
       "eric7/UI/upgrader.py",
+      "eric7/Unittest/Interfaces/PytestExecutor.py",
+      "eric7/Unittest/Interfaces/PytestRunner.py",
+      "eric7/Unittest/Interfaces/UTExecutorBase.py",
+      "eric7/Unittest/Interfaces/UTFrameworkRegistry.py",
+      "eric7/Unittest/Interfaces/UnittestExecutor.py",
+      "eric7/Unittest/Interfaces/UnittestRunner.py",
+      "eric7/Unittest/Interfaces/__init__.py",
+      "eric7/Unittest/UTTestResultsTree.py",
+      "eric7/Unittest/UnittestWidget.py",
+      "eric7/Unittest/__init__.py",
       "eric7/Utilities/AutoSaver.py",
       "eric7/Utilities/BackgroundClient.py",
       "eric7/Utilities/BackgroundService.py",
--- a/eric7/Globals/__init__.py	Sun May 08 19:58:27 2022 +0200
+++ b/eric7/Globals/__init__.py	Thu May 12 08:59:13 2022 +0200
@@ -38,6 +38,8 @@
 recentNameUnittestDiscoverHistory = "UTDiscoverHistory"
 recentNameUnittestFileHistory = "UTFileHistory"
 recentNameUnittestTestnameHistory = "UTTestnameHistory"
+recentNameUnittestFramework = "UTTestFramework"
+recentNameUnittestEnvironment = "UTEnvironmentName"
 
 configDir = None
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric7/Unittest/Interfaces/PytestExecutor.py	Thu May 12 08:59:13 2022 +0200
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing the executor for the 'pytest' framework.
+"""
+
+import contextlib
+import json
+import os
+
+from PyQt6.QtCore import QProcess
+
+from .UTExecutorBase import UTExecutorBase
+
+
+class PytestExecutor(UTExecutorBase):
+    """
+    Class implementing the executor for the 'pytest' framework.
+    """
+    module = "pytest"
+    name = "pytest"
+    
+    runner = os.path.join(os.path.dirname(__file__), "PytestRunner.py")
+    
+    def getVersions(self, interpreter):
+        """
+        Public method to get the test framework version and version information
+        of its installed plugins.
+        
+        @param interpreter interpreter to be used for the test
+        @type str
+        @return dictionary containing the framework name and version and the
+            list of available plugins with name and version each
+        @rtype dict
+        """
+        proc = QProcess()
+        proc.start(interpreter, [PytestExecutor.runner, "versions"])
+        if proc.waitForFinished(3000):
+            exitCode = proc.exitCode()
+            if exitCode == 0:
+                outputLines = self.readAllOutput(proc).splitlines()
+                for line in outputLines:
+                    if line.startswith("{") and line.endswith("}"):
+                        with contextlib.suppress(json.JSONDecodeError):
+                            return json.loads(line)
+        
+        return {}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric7/Unittest/Interfaces/PytestRunner.py	Thu May 12 08:59:13 2022 +0200
@@ -0,0 +1,92 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing the test runner script for the 'pytest' framework.
+"""
+
+import json
+import sys
+
+
+class GetPluginVersionsPlugin():
+    """
+    Class implementing a pytest plugin to extract the version info of all
+    installed plugins.
+    """
+    def __init__(self):
+        """
+        Constructor
+        """
+        super().__init__()
+        
+        self.versions = []
+    
+    def pytest_cmdline_main(self, config):
+        """
+        Public method called for performing the main command line action.
+        
+        @param config pytest config object
+        @type Config
+        """
+        pluginInfo = config.pluginmanager.list_plugin_distinfo()
+        if pluginInfo:
+            for _plugin, dist in pluginInfo:
+                self.versions.append({
+                    "name": dist.project_name,
+                    "version": dist.version
+                })
+    
+    def getVersions(self):
+        """
+        Public method to get the assembled list of plugin versions.
+        
+        @return list of collected plugin versions
+        @rtype list of dict
+        """
+        return self.versions
+
+
+def getVersions():
+    """
+    Function to determine the framework version and versions of all available
+    plugins.
+    """
+    try:
+        import pytest               # __IGNORE_WARNING__
+        versions = {
+            "name": "pytest",
+            "version": pytest.__version__,
+            "plugins": [],
+        }
+        
+        # --capture=sys needed on Windows to avoid
+        # ValueError: saved filedescriptor not valid anymore
+        plugin = GetPluginVersionsPlugin()
+        pytest.main(['--version', '--capture=sys'], plugins=[plugin])
+        versions["plugins"] = plugin.getVersions()
+    except ImportError:
+        versions = {}
+    
+    print(json.dumps(versions))
+    sys.exit(0)
+
+
+if __name__ == '__main__':
+    command = sys.argv[1]
+    if command == "installed":
+        try:
+            import pytest           # __IGNORE_WARNING__
+            sys.exit(0)
+        except ImportError:
+            sys.exit(1)
+    
+    elif command == "versions":
+        getVersions()
+    
+    sys.exit(42)
+
+#
+# eflag: noqa = M801
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric7/Unittest/Interfaces/UTExecutorBase.py	Thu May 12 08:59:13 2022 +0200
@@ -0,0 +1,249 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing the executor base class for the various testing frameworks
+and supporting classes.
+"""
+
+import os
+from dataclasses import dataclass
+from enum import IntEnum
+
+from PyQt6.QtCore import pyqtSignal, QObject, QProcess, QProcessEnvironment
+
+import Preferences
+
+
+class ResultCategory(IntEnum):
+    """
+    Class defining the supported result categories.
+    """
+    FAIL = 1
+    OK = 2
+    SKIP = 3
+    PENDING = 4
+
+
+@dataclass
+class UTTestResult:
+    """
+    Class containing the test result data.
+    """
+    category: int               # result category
+    status: str                 # test status
+    name: str                   # test name
+    message: str                # short result message
+    extra: str                  # additional information text
+    duration: float             # test duration
+    filename: str               # file name of a failed test
+    lineno: int                 # line number of a failed test
+
+
+@dataclass
+class UTTestConfig:
+    """
+    Class containing the test run configuration.
+    """
+    interpreter: str            # path of the Python interpreter
+    discover: bool              # auto discovery flag
+    discoveryStart: str         # start directory for auto discovery
+    testFilename: str           # name of the test script
+    testName: str               # name of the test function
+    failFast: bool              # stop on first fail
+    collectCoverage: bool       # coverage collection flag
+    eraseCoverage: bool         # erase coverage data first
+
+
+class UTExecutorBase(QObject):
+    """
+    Base class for test framework specific implementations.
+    
+    @signal collected(list of str) emitted after all tests have been
+        collected
+    @signal collectError(list of tuple of (str, str)) emitted when errors
+        are encountered during test collection. Tuple elements are the
+        test name and the error message.
+    @signal startTest(list of str) emitted before tests are run
+    @signal testResult(UTTestResult) emitted when a test result is ready
+    @signal testFinished(list, str) emitted when the test has finished.
+        The elements are the list of test results and the captured output
+        of the test worker (if any).
+    @signal stop() emitted when the test process is being stopped.
+    """
+    collected = pyqtSignal(list)
+    collectError = pyqtSignal(list)
+    startTest = pyqtSignal(list)
+    testResult = pyqtSignal(UTTestResult)
+    testFinished = pyqtSignal(list, str)
+    stop = pyqtSignal()
+    
+    module = ""
+    name = ""
+    runner = ""
+    
+    def __init__(self, testWidget, logfile=None):
+        """
+        Constructor
+        
+        @param testWidget reference to the unit test widget
+        @type UnittestWidget
+        @param logfile file name to log test results to (defaults to None)
+        @type str (optional)
+        """
+        super().__init__(testWidget)
+        
+        self.__process = None
+        self._logfile = logfile
+        # TODO: add log file creation
+    
+    @classmethod
+    def isInstalled(cls, interpreter):
+        """
+        Class method to check whether a test framework is installed.
+        
+        The test is performed by checking, if a module loader can found.
+        
+        @param interpreter interpreter to be used for the test
+        @type str
+        @return flag indicating the test framework module is installed
+        @rtype bool
+        """
+        if cls.runner:
+            proc = QProcess()
+            proc.start(interpreter, [cls.runner, "installed"])
+            if proc.waitForFinished(3000):
+                exitCode = proc.exitCode()
+                return exitCode == 0
+        
+        return False
+    
+    def getVersions(self, interpreter):
+        """
+        Public method to get the test framework version and version information
+        of its installed plugins.
+        
+        @param interpreter interpreter to be used for the test
+        @type str
+        @return dictionary containing the framework name and version and the
+            list of available plugins with name and version each
+        @rtype dict
+        @exception NotImplementedError this method needs to be implemented by
+            derived classes
+        """
+        raise NotImplementedError
+        
+        return {}
+    
+    def createArguments(self, config):
+        """
+        Public method to create the arguments needed to start the test process.
+        
+        @param config configuration for the test execution
+        @type UTTestConfig
+        @return list of process arguments
+        @rtype list of str
+        @exception NotImplementedError this method needs to be implemented by
+            derived classes
+        """
+        raise NotImplementedError
+        
+        return []
+    
+    def _prepareProcess(self, workDir, pythonpath):
+        """
+        Protected method to prepare a process object to be started.
+        
+        @param workDir working directory
+        @type str
+        @param pythonpath list of directories to be added to the Python path
+        @type list of str
+        @return prepared process object
+        @rtype QProcess
+        """
+        process = QProcess(self)
+        process.setProcessChannelMode(
+            QProcess.ProcessChannelMode.MergedChannels)
+        process.setWorkingDirectory(workDir)
+        process.finished.connect(self.finished)
+        if pythonpath:
+            env = QProcessEnvironment.systemEnvironment()
+            currentPythonPath = env.value('PYTHONPATH', None)
+            newPythonPath = os.pathsep.join(pythonpath)
+            if currentPythonPath:
+                newPythonPath += os.pathsep + currentPythonPath
+            env.insert('PYTHONPATH', newPythonPath)
+            process.setProcessEnvironment(env)
+        
+        return process
+    
+    def start(self, config, pythonpath):
+        """
+        Public method to start the testing process.
+        
+        @param config configuration for the test execution
+        @type UTTestConfig
+        @param pythonpath list of directories to be added to the Python path
+        @type list of str
+        @exception RuntimeError raised if the the testing process did not start
+        """
+        workDir = (
+            config.discoveryStart
+            if config.discover else
+            os.path.dirname(config.testFilename)
+        )
+        self.__process = self._prepareProcess(workDir, pythonpath)
+        testArgs = self.createArguments(config)
+        self.__process.start(config.interpreter, testArgs)
+        running = self.__process.waitForStarted()
+        if not running:
+            raise RuntimeError
+    
+    def finished(self):
+        """
+        Public method handling the unit test process been finished.
+        
+        This method should read the results (if necessary) and emit the signal
+        testFinished.
+        
+        @exception NotImplementedError this method needs to be implemented by
+            derived classes
+        """
+        raise NotImplementedError
+    
+    def readAllOutput(self, process=None):
+        """
+        Public method to read all output of the test process.
+        
+        @param process reference to the process object
+        @type QProcess
+        @return test process output
+        @rtype str
+        """
+        if process is None:
+            process = self.__process
+        output = (
+            str(process.readAllStandardOutput(),
+                Preferences.getSystem("IOEncoding"),
+                'replace').strip()
+            if process else
+            ""
+        )
+        return output
+    
+    def stopIfRunning(self):
+        """
+        Public method to stop the testing process, if it is running.
+        """
+        if (
+            self.__process and
+            self.__process.state() == QProcess.ProcessState.Running
+        ):
+            self.__process.terminate()
+            self.__process.waitForFinished(2000)
+            self.__process.kill()
+            self.__process.waitForFinished(3000)
+            
+            self.stop.emit()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric7/Unittest/Interfaces/UTFrameworkRegistry.py	Thu May 12 08:59:13 2022 +0200
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing a simple registry containing the available test framework
+interfaces.
+"""
+
+import copy
+
+
+class UTFrameworkRegistry():
+    """
+    Class implementing a simple registry of test framework interfaces.
+    
+    The test executor for a framework is responsible for running the tests,
+    receiving the results and preparing them for display. It must implement
+    the interface of UTExecutorBase.
+
+    Frameworks must first be registered using '.register()'. This registry
+    can then create the assoicated test executor when '.createExecutor()' is
+    called.
+    """
+    def __init__(self):
+        """
+        Constructor
+        """
+        self.__frameworks = {}
+    
+    def register(self, executorClass):
+        """
+        Public method to register a test framework executor.
+        
+        @param executorClass class implementing the test framework executor
+        @type UTExecutorBase
+        """
+        self.__frameworks[executorClass.name] = executorClass
+    
+    def createExecutor(self, framework, widget, logfile=None):
+        """
+        Public method to create a test framework executor.
+        
+        Note: The executor classes have to be registered first.
+        
+        @param framework name of the test framework
+        @type str
+        @param widget reference to the unit test widget
+        @type UnittestWidget
+        @param logfile file name to log test results to (defaults to None)
+        @type str (optional)
+        @return test framework executor object
+        """
+        cls = self.__frameworks[framework]
+        return cls(widget, logfile=logfile)
+    
+    def getFrameworks(self):
+        """
+        Public method to get a copy of the registered frameworks.
+        
+        @return  copy of the registered frameworks
+        @rtype dict
+        """
+        return copy.copy(self.__frameworks)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric7/Unittest/Interfaces/UnittestExecutor.py	Thu May 12 08:59:13 2022 +0200
@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing the executor for the standard 'unittest' framework.
+"""
+
+import contextlib
+import json
+import os
+
+from PyQt6.QtCore import QProcess
+
+from .UTExecutorBase import UTExecutorBase
+
+
+class UnittestExecutor(UTExecutorBase):
+    """
+    Class implementing the executor for the standard 'unittest' framework.
+    """
+    module = "unittest"
+    name = "unittest"
+    
+    runner = os.path.join(os.path.dirname(__file__), "UnittestRunner.py")
+    
+    def getVersions(self, interpreter):
+        """
+        Public method to get the test framework version and version information
+        of its installed plugins.
+        
+        @param interpreter interpreter to be used for the test
+        @type str
+        @return dictionary containing the framework name and version and the
+            list of available plugins with name and version each
+        @rtype dict
+        """
+        proc = QProcess()
+        proc.start(interpreter, [UnittestExecutor.runner, "versions"])
+        if proc.waitForFinished(3000):
+            exitCode = proc.exitCode()
+            if exitCode == 0:
+                versionsStr = self.readAllOutput(proc)
+                with contextlib.suppress(json.JSONDecodeError):
+                    return json.loads(versionsStr)
+        
+        return {}
+    
+    def createArguments(self, config):
+        """
+        Public method to create the arguments needed to start the test process.
+        
+        @param config configuration for the test execution
+        @type UTTestConfig
+        @return list of process arguments
+        @rtype list of str
+        @exception NotImplementedError this method needs to be implemented by
+            derived classes
+        """
+        raise NotImplementedError
+        
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric7/Unittest/Interfaces/UnittestRunner.py	Thu May 12 08:59:13 2022 +0200
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing the test runner script for the 'unittest' framework.
+"""
+
+import json
+import sys
+
+if __name__ == '__main__':
+    command = sys.argv[1]
+    if command == "installed":
+        try:
+            import unittest         # __IGNORE_WARNING__
+            sys.exit(0)
+        except ImportError:
+            sys.exit(1)
+    
+    elif command == "versions":
+        import platform
+        versions = {
+            "name": "unittest",
+            "version": platform.python_version(),
+            "plugins": [],
+        }
+        print(json.dumps(versions))
+        sys.exit(0)
+    
+    sys.exit(42)
+
+#
+# eflag: noqa = M801
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric7/Unittest/Interfaces/__init__.py	Thu May 12 08:59:13 2022 +0200
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Package containg the various test framework interfaces.
+"""
+
+from .PytestExecutor import PytestExecutor
+from .UnittestExecutor import UnittestExecutor
+
+Frameworks = (
+    UnittestExecutor,
+    PytestExecutor,
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric7/Unittest/UTTestResultsTree.py	Thu May 12 08:59:13 2022 +0200
@@ -0,0 +1,147 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing a tree view and associated model to show the test result
+data.
+"""
+
+from PyQt6.QtCore import (
+    pyqtSignal, pyqtSlot, Qt, QAbstractItemModel, QCoreApplication, QModelIndex
+)
+from PyQt6.QtWidgets import QTreeView
+
+TopLevelId = 2 ** 32 - 1
+
+
+class TestResultsModel(QAbstractItemModel):
+    """
+    Class implementing the item model containing the test data.
+    """
+    Headers = [
+        QCoreApplication.translate("TestResultsModel", "Status"),
+        QCoreApplication.translate("TestResultsModel", "Name"),
+        QCoreApplication.translate("TestResultsModel", "Message"),
+        QCoreApplication.translate("TestResultsModel", "Duration (ms)"),
+    ]
+    
+    def __init__(self, parent=None):
+        """
+        Constructor
+        
+        @param parent reference to the parent object (defaults to None)
+        @type QObject (optional)
+        """
+        super().__init__(parent)
+        
+        self.__testResults = []
+    
+    def headerData(self, section, orientation,
+                   role=Qt.ItemDataRole.DisplayRole):
+        """
+        Public method to get the header string for the various sections.
+        
+        @param section section number
+        @type int
+        @param orientation orientation of the header
+        @type Qt.Orientation
+        @param role data role (defaults to Qt.ItemDataRole.DisplayRole)
+        @type Qt.ItemDataRole (optional)
+        @return header string of the section
+        @rtype str
+        """
+        if (
+            orientation == Qt.Orientation.Horizontal and
+            role == Qt.ItemDataRole.DisplayRole
+        ):
+            return TestResultsModel.Headers[section]
+        else:
+            return None
+    
+    def rowCount(self, parent=QModelIndex()):
+        """
+        Public method to get the number of row for a given parent index.
+        
+        @param parent index of the parent item (defaults to QModelIndex())
+        @type QModelIndex (optional)
+        @return number of rows
+        @rtype int
+        """
+        if not parent.isValid():
+            return len(self.__testResults)
+        
+        if parent.internalId() == TopLevelId and parent.column() == 0:
+            return len(self.__testResults[parent.row()].extra)
+        
+        return 0
+
+    def columnCount(self, parent=QModelIndex()):
+        """
+        Public method to get the number of columns.
+        
+        @param parent index of the parent item (defaults to QModelIndex())
+        @type QModelIndex (optional)
+        @return number of columns
+        @rtype int
+        """
+        if not parent.isValid():
+            return len(TestResultsModel.Headers)
+        else:
+            return 1
+    
+    def clear(self):
+        """
+        Public method to clear the model data.
+        """
+        self.beginResetModel()
+        self.__testResults.clear()
+        self.endResetModel()
+
+
+class TestResultsTreeView(QTreeView):
+    """
+    Class implementing a tree view to show the test result data.
+    
+    @signal goto(str, int) emitted to go to the position given by file name
+        and line number
+    """
+    goto = pyqtSignal(str, int)
+    
+    def __init__(self, parent=None):
+        """
+        Constructor
+        
+        @param parent reference to the parent widget (defaults to None)
+        @type QWidget (optional)
+        """
+        super().__init__(parent)
+        
+        self.setItemsExpandable(True)
+        self.setExpandsOnDoubleClick(False)
+        self.setSortingEnabled(True)
+        
+        self.header().setDefaultAlignment(Qt.AlignmentFlag.AlignCenter)
+        self.header().setSortIndicatorShown(False)
+        
+        # connect signals and slots
+        self.doubleClicked.connect(self.__gotoTestDefinition)
+        
+        self.header().sortIndicatorChanged.connect(self.sortByColumn)
+        self.header().sortIndicatorChanged.connect(
+            lambda column, order: self.header().setSortIndicatorShown(True))
+    
+    @pyqtSlot(QModelIndex)
+    def __gotoTestDefinition(self, index):
+        """
+        Private slot to show the test definition.
+        
+        @param index index for the double-clicked item
+        @type QModelIndex
+        """
+        # TODO: not implemented yet
+        pass
+
+#
+# eflag: noqa = M822
--- /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()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric7/Unittest/UnittestWidget.ui	Thu May 12 08:59:13 2022 +0200
@@ -0,0 +1,547 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>UnittestWidget</class>
+ <widget class="QWidget" name="UnittestWidget">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>650</width>
+    <height>700</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Unittest</string>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout_3">
+   <item>
+    <widget class="QTabWidget" name="tabWidget">
+     <property name="currentIndex">
+      <number>0</number>
+     </property>
+     <widget class="QWidget" name="parametersTab">
+      <attribute name="title">
+       <string>Parameters</string>
+      </attribute>
+      <layout class="QVBoxLayout" name="verticalLayout">
+       <item>
+        <layout class="QGridLayout" name="gridLayout_3">
+         <item row="0" column="0">
+          <widget class="QLabel" name="venvLabel">
+           <property name="text">
+            <string>Virtual Environment:</string>
+           </property>
+           <property name="buddy">
+            <cstring>venvComboBox</cstring>
+           </property>
+          </widget>
+         </item>
+         <item row="0" column="1" colspan="2">
+          <widget class="QComboBox" name="venvComboBox">
+           <property name="sizePolicy">
+            <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+             <horstretch>0</horstretch>
+             <verstretch>0</verstretch>
+            </sizepolicy>
+           </property>
+           <property name="toolTip">
+            <string>Select the virtual environment to be used</string>
+           </property>
+           <property name="whatsThis">
+            <string>&lt;b&gt;Virtual Environment&lt;/b&gt;\n&lt;p&gt;Enter the virtual environment to be used. Leave it empty to use the default environment, i.e. the one configured globally or per project.&lt;/p&gt;</string>
+           </property>
+          </widget>
+         </item>
+         <item row="1" column="0">
+          <widget class="QLabel" name="label">
+           <property name="text">
+            <string>Test Framework:</string>
+           </property>
+          </widget>
+         </item>
+         <item row="1" column="1">
+          <widget class="QComboBox" name="frameworkComboBox">
+           <property name="sizePolicy">
+            <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+             <horstretch>0</horstretch>
+             <verstretch>0</verstretch>
+            </sizepolicy>
+           </property>
+           <property name="toolTip">
+            <string>Select the test framwork to be used</string>
+           </property>
+          </widget>
+         </item>
+         <item row="1" column="2">
+          <widget class="QToolButton" name="versionsButton">
+           <property name="toolTip">
+            <string>Press to show the test framework versions</string>
+           </property>
+          </widget>
+         </item>
+        </layout>
+       </item>
+       <item>
+        <widget class="QGroupBox" name="groupBox">
+         <property name="title">
+          <string>Test Parameters</string>
+         </property>
+         <layout class="QGridLayout" name="gridLayout">
+          <item row="0" column="0" colspan="2">
+           <layout class="QHBoxLayout" name="horizontalLayout_4">
+            <item>
+             <widget class="QCheckBox" name="discoverCheckBox">
+              <property name="sizePolicy">
+               <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+                <horstretch>0</horstretch>
+                <verstretch>0</verstretch>
+               </sizepolicy>
+              </property>
+              <property name="toolTip">
+               <string>Select to discover tests automatically</string>
+              </property>
+              <property name="text">
+               <string>Discover tests (test modules must be importable)</string>
+              </property>
+              <property name="checked">
+               <bool>true</bool>
+              </property>
+             </widget>
+            </item>
+            <item>
+             <widget class="QToolButton" name="clearHistoriesButton">
+              <property name="toolTip">
+               <string>Press to clear the various histories</string>
+              </property>
+             </widget>
+            </item>
+           </layout>
+          </item>
+          <item row="1" column="0">
+           <widget class="QLabel" name="label_3">
+            <property name="text">
+             <string>Discovery Start:</string>
+            </property>
+            <property name="buddy">
+             <cstring>discoveryPicker</cstring>
+            </property>
+           </widget>
+          </item>
+          <item row="1" column="1">
+           <widget class="EricComboPathPicker" name="discoveryPicker" native="true">
+            <property name="sizePolicy">
+             <sizepolicy hsizetype="Expanding" vsizetype="Preferred">
+              <horstretch>0</horstretch>
+              <verstretch>0</verstretch>
+             </sizepolicy>
+            </property>
+            <property name="focusPolicy">
+             <enum>Qt::WheelFocus</enum>
+            </property>
+            <property name="toolTip">
+             <string>Enter name of the directory at which to start the test file discovery</string>
+            </property>
+            <property name="whatsThis">
+             <string>&lt;b&gt;Discovery Start&lt;/b&gt;
+&lt;p&gt;Enter name of the directory at which to start the test file discovery.
+Note that all test modules must be importable from this directory.&lt;/p&gt;</string>
+            </property>
+           </widget>
+          </item>
+          <item row="2" column="0">
+           <widget class="QLabel" name="testsuiteLabel">
+            <property name="text">
+             <string>Test Filename:</string>
+            </property>
+            <property name="buddy">
+             <cstring>testsuitePicker</cstring>
+            </property>
+           </widget>
+          </item>
+          <item row="2" column="1">
+           <widget class="EricComboPathPicker" name="testsuitePicker" native="true">
+            <property name="enabled">
+             <bool>false</bool>
+            </property>
+            <property name="sizePolicy">
+             <sizepolicy hsizetype="Expanding" vsizetype="Preferred">
+              <horstretch>0</horstretch>
+              <verstretch>0</verstretch>
+             </sizepolicy>
+            </property>
+            <property name="focusPolicy">
+             <enum>Qt::WheelFocus</enum>
+            </property>
+            <property name="toolTip">
+             <string>Enter name of file defining the testsuite</string>
+            </property>
+            <property name="whatsThis">
+             <string>&lt;b&gt;Testsuite&lt;/b&gt;
+&lt;p&gt;Enter the name of the file defining the testsuite.
+It should have a method with a name given below. If no name is given, the suite() method will be tried. If no such method can be
+found, the module will be inspected for proper test
+cases.&lt;/p&gt;</string>
+            </property>
+           </widget>
+          </item>
+          <item row="3" column="0">
+           <widget class="QLabel" name="label_2">
+            <property name="text">
+             <string>Test Name:</string>
+            </property>
+            <property name="buddy">
+             <cstring>testComboBox</cstring>
+            </property>
+           </widget>
+          </item>
+          <item row="3" column="1">
+           <widget class="QComboBox" name="testComboBox">
+            <property name="enabled">
+             <bool>false</bool>
+            </property>
+            <property name="toolTip">
+             <string>Enter the test name. Leave empty to use the default name &quot;suite&quot;.</string>
+            </property>
+            <property name="whatsThis">
+             <string>&lt;b&gt;Testname&lt;/b&gt;&lt;p&gt;Enter the name of the test to be performed. This name must follow the rules given by Python's unittest module. If this field is empty, the default name of &quot;suite&quot; will be used.&lt;/p&gt;</string>
+            </property>
+            <property name="editable">
+             <bool>true</bool>
+            </property>
+           </widget>
+          </item>
+         </layout>
+        </widget>
+       </item>
+       <item>
+        <widget class="QGroupBox" name="optionsGroup">
+         <property name="title">
+          <string>Run Parameters</string>
+         </property>
+         <layout class="QVBoxLayout" name="verticalLayout_2">
+          <item>
+           <layout class="QGridLayout" name="gridLayout_2">
+            <item row="0" column="0">
+             <widget class="QCheckBox" name="coverageCheckBox">
+              <property name="toolTip">
+               <string>Select whether coverage data should be collected</string>
+              </property>
+              <property name="text">
+               <string>Collect coverage data</string>
+              </property>
+             </widget>
+            </item>
+            <item row="0" column="1">
+             <widget class="QCheckBox" name="coverageEraseCheckBox">
+              <property name="enabled">
+               <bool>false</bool>
+              </property>
+              <property name="toolTip">
+               <string>Select whether old coverage data should be erased</string>
+              </property>
+              <property name="text">
+               <string>&amp;Erase coverage data</string>
+              </property>
+             </widget>
+            </item>
+            <item row="1" column="0">
+             <widget class="QCheckBox" name="failfastCheckBox">
+              <property name="toolTip">
+               <string>Select to stop the test run on the first error or failure</string>
+              </property>
+              <property name="text">
+               <string>Stop on First Error or Failure</string>
+              </property>
+             </widget>
+            </item>
+           </layout>
+          </item>
+         </layout>
+        </widget>
+       </item>
+       <item>
+        <spacer name="verticalSpacer">
+         <property name="orientation">
+          <enum>Qt::Vertical</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>20</width>
+           <height>239</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+      </layout>
+     </widget>
+     <widget class="QWidget" name="resultsTab">
+      <attribute name="title">
+       <string>Results</string>
+      </attribute>
+      <layout class="QVBoxLayout" name="verticalLayout_6">
+       <item>
+        <widget class="QGroupBox" name="progressGroupBox">
+         <property name="title">
+          <string>Progress</string>
+         </property>
+         <layout class="QVBoxLayout" name="verticalLayout_4">
+          <item>
+           <widget class="QProgressBar" name="progressProgressBar">
+            <property name="value">
+             <number>0</number>
+            </property>
+            <property name="orientation">
+             <enum>Qt::Horizontal</enum>
+            </property>
+            <property name="format">
+             <string>%v/%m Tests</string>
+            </property>
+           </widget>
+          </item>
+          <item>
+           <layout class="QHBoxLayout" name="horizontalLayout_2">
+            <item>
+             <widget class="QLabel" name="progressCounterRunLabel">
+              <property name="text">
+               <string>Run:</string>
+              </property>
+             </widget>
+            </item>
+            <item>
+             <widget class="QLabel" name="progressCounterRunCount">
+              <property name="toolTip">
+               <string>Number of tests run</string>
+              </property>
+              <property name="text">
+               <string notr="true">0</string>
+              </property>
+             </widget>
+            </item>
+            <item>
+             <widget class="QLabel" name="progressCounterRemLabel">
+              <property name="text">
+               <string>Remaining:</string>
+              </property>
+             </widget>
+            </item>
+            <item>
+             <widget class="QLabel" name="progressCounterRemCount">
+              <property name="toolTip">
+               <string>Number of tests to be run</string>
+              </property>
+              <property name="text">
+               <string notr="true">0</string>
+              </property>
+             </widget>
+            </item>
+            <item>
+             <spacer name="horizontalSpacer">
+              <property name="orientation">
+               <enum>Qt::Horizontal</enum>
+              </property>
+              <property name="sizeHint" stdset="0">
+               <size>
+                <width>40</width>
+                <height>20</height>
+               </size>
+              </property>
+             </spacer>
+            </item>
+           </layout>
+          </item>
+         </layout>
+        </widget>
+       </item>
+       <item>
+        <widget class="QGroupBox" name="resultsGroupBox">
+         <property name="sizePolicy">
+          <sizepolicy hsizetype="Preferred" vsizetype="Expanding">
+           <horstretch>0</horstretch>
+           <verstretch>0</verstretch>
+          </sizepolicy>
+         </property>
+         <property name="title">
+          <string>Results</string>
+         </property>
+         <layout class="QVBoxLayout" name="verticalLayout_5">
+          <item>
+           <widget class="QLabel" name="statusLabel">
+            <property name="text">
+             <string/>
+            </property>
+           </widget>
+          </item>
+         </layout>
+        </widget>
+       </item>
+      </layout>
+     </widget>
+    </widget>
+   </item>
+   <item>
+    <layout class="QHBoxLayout" name="_4">
+     <item>
+      <widget class="QLabel" name="sbLabel_2">
+       <property name="sizePolicy">
+        <sizepolicy hsizetype="Preferred" vsizetype="Preferred">
+         <horstretch>0</horstretch>
+         <verstretch>0</verstretch>
+        </sizepolicy>
+       </property>
+       <property name="text">
+        <string>Idle</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <spacer>
+       <property name="orientation">
+        <enum>Qt::Horizontal</enum>
+       </property>
+       <property name="sizeType">
+        <enum>QSizePolicy::Expanding</enum>
+       </property>
+       <property name="sizeHint" stdset="0">
+        <size>
+         <width>20</width>
+         <height>20</height>
+        </size>
+       </property>
+      </spacer>
+     </item>
+    </layout>
+   </item>
+   <item>
+    <widget class="QDialogButtonBox" name="buttonBox">
+     <property name="orientation">
+      <enum>Qt::Horizontal</enum>
+     </property>
+     <property name="standardButtons">
+      <set>QDialogButtonBox::Close</set>
+     </property>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <customwidgets>
+  <customwidget>
+   <class>EricComboPathPicker</class>
+   <extends>QWidget</extends>
+   <header>EricWidgets/EricPathPicker.h</header>
+   <container>1</container>
+  </customwidget>
+ </customwidgets>
+ <tabstops>
+  <tabstop>tabWidget</tabstop>
+  <tabstop>venvComboBox</tabstop>
+  <tabstop>frameworkComboBox</tabstop>
+  <tabstop>versionsButton</tabstop>
+  <tabstop>discoverCheckBox</tabstop>
+  <tabstop>clearHistoriesButton</tabstop>
+  <tabstop>discoveryPicker</tabstop>
+  <tabstop>testsuitePicker</tabstop>
+  <tabstop>testComboBox</tabstop>
+  <tabstop>coverageCheckBox</tabstop>
+  <tabstop>coverageEraseCheckBox</tabstop>
+  <tabstop>failfastCheckBox</tabstop>
+ </tabstops>
+ <resources/>
+ <connections>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>accepted()</signal>
+   <receiver>UnittestWidget</receiver>
+   <slot>close()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>31</x>
+     <y>648</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>1</x>
+     <y>510</y>
+    </hint>
+   </hints>
+  </connection>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>rejected()</signal>
+   <receiver>UnittestWidget</receiver>
+   <slot>close()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>80</x>
+     <y>649</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>3</x>
+     <y>580</y>
+    </hint>
+   </hints>
+  </connection>
+  <connection>
+   <sender>discoverCheckBox</sender>
+   <signal>toggled(bool)</signal>
+   <receiver>discoveryPicker</receiver>
+   <slot>setEnabled(bool)</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>168</x>
+     <y>164</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>170</x>
+     <y>191</y>
+    </hint>
+   </hints>
+  </connection>
+  <connection>
+   <sender>discoverCheckBox</sender>
+   <signal>toggled(bool)</signal>
+   <receiver>testsuitePicker</receiver>
+   <slot>setDisabled(bool)</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>222</x>
+     <y>162</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>222</x>
+     <y>209</y>
+    </hint>
+   </hints>
+  </connection>
+  <connection>
+   <sender>discoverCheckBox</sender>
+   <signal>toggled(bool)</signal>
+   <receiver>testComboBox</receiver>
+   <slot>setDisabled(bool)</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>301</x>
+     <y>163</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>300</x>
+     <y>238</y>
+    </hint>
+   </hints>
+  </connection>
+  <connection>
+   <sender>coverageCheckBox</sender>
+   <signal>toggled(bool)</signal>
+   <receiver>coverageEraseCheckBox</receiver>
+   <slot>setEnabled(bool)</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>160</x>
+     <y>320</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>369</x>
+     <y>319</y>
+    </hint>
+   </hints>
+  </connection>
+ </connections>
+</ui>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric7/Unittest/__init__.py	Thu May 12 08:59:13 2022 +0200
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Package implementing unit test functionality and interface to various unit test
+frameworks.
+"""
--- a/eric7/eric7_unittest.py	Sun May 08 19:58:27 2022 +0200
+++ b/eric7/eric7_unittest.py	Thu May 12 08:59:13 2022 +0200
@@ -41,10 +41,12 @@
     """
     Function to create the main widget.
     
-    @param argv list of commandline parameters (list of strings)
-    @return reference to the main widget (QWidget)
+    @param argv list of commandline parameters
+    @type list of str
+    @return reference to the main widget
+    @rtype QWidget
     """
-    from PyUnit.UnittestDialog import UnittestWindow
+    from Unittest.UnittestWidget import UnittestWindow
     try:
         fn = argv[1]
     except IndexError:

eric ide

mercurial