src/eric7/Testing/TestingWidget.py

branch
eric7
changeset 10405
df7e1694d0eb
parent 10404
f7d9c31f0c38
child 10413
2ecbe43a8e88
--- a/src/eric7/Testing/TestingWidget.py	Tue Dec 12 16:43:51 2023 +0100
+++ b/src/eric7/Testing/TestingWidget.py	Wed Dec 13 15:54:55 2023 +0100
@@ -13,7 +13,13 @@
 import os
 
 from PyQt6.QtCore import QCoreApplication, QEvent, Qt, pyqtSignal, pyqtSlot
-from PyQt6.QtWidgets import QAbstractButton, QComboBox, QDialogButtonBox, QWidget
+from PyQt6.QtWidgets import (
+    QAbstractButton,
+    QComboBox,
+    QDialogButtonBox,
+    QTreeWidgetItem,
+    QWidget,
+)
 
 from eric7 import Preferences
 from eric7.DataViews.PyCoverageDialog import PyCoverageDialog
@@ -33,7 +39,11 @@
 from .Interfaces import Frameworks
 from .Interfaces.TestExecutorBase import TestConfig, TestResult, TestResultCategory
 from .Interfaces.TestFrameworkRegistry import TestFrameworkRegistry
-from .TestResultsTree import TestResultsFilterModel, TestResultsModel, TestResultsTreeView
+from .TestResultsTree import (
+    TestResultsFilterModel,
+    TestResultsModel,
+    TestResultsTreeView,
+)
 from .Ui_TestingWidget import Ui_TestingWidget
 
 
@@ -45,6 +55,7 @@
     IDLE = 0  # idle, no test were run yet
     RUNNING = 1  # test run being performed
     STOPPED = 2  # test run finished
+    DISCOVERY = 3  # discovery of tests being performed
 
 
 class TestingWidget(QWidget, Ui_TestingWidget):
@@ -59,6 +70,11 @@
     testFile = pyqtSignal(str, int, bool)
     testRunStopped = pyqtSignal()
 
+    TestCaseNameRole = Qt.ItemDataRole.UserRole
+    TestCaseFileRole = Qt.ItemDataRole.UserRole + 1
+    TestCaseLinenoRole = Qt.ItemDataRole.UserRole + 2
+    TestCaseIdRole = Qt.ItemDataRole.UserRole + 3
+
     def __init__(self, testfile=None, parent=None):
         """
         Constructor
@@ -132,11 +148,22 @@
             )
         )
 
+        self.__discoverButton = self.buttonBox.addButton(
+            self.tr("Discover"), QDialogButtonBox.ButtonRole.ActionRole
+        )
+        self.__discoverButton.setToolTip(self.tr("Discover Tests"))
+        self.__discoverButton.setWhatsThis(
+            self.tr(
+                """<b>Discover Tests</b>"""
+                """<p>This button starts a discovery of available tests.</p>"""
+            )
+        )
+
         self.__startButton = self.buttonBox.addButton(
             self.tr("Start"), QDialogButtonBox.ButtonRole.ActionRole
         )
 
-        self.__startButton.setToolTip(self.tr("Start the selected testsuite"))
+        self.__startButton.setToolTip(self.tr("Start the selected test suite"))
         self.__startButton.setWhatsThis(
             self.tr("""<b>Start Test</b><p>This button starts the test run.</p>""")
         )
@@ -500,6 +527,22 @@
         """
         failedAvailable = bool(self.__resultsModel.getFailedTests())
 
+        # Discover button
+        if self.__mode in (TestingWidgetModes.IDLE, TestingWidgetModes.STOPPED):
+            self.__discoverButton.setEnabled(
+                bool(self.venvComboBox.currentText())
+                and bool(self.frameworkComboBox.currentText())
+                and (
+                    (
+                        self.discoverCheckBox.isChecked()
+                        and bool(self.discoveryPicker.currentText())
+                    )
+                )
+            )
+        else:
+            self.__discoverButton.setEnabled(False)
+            self.__discoverButton.setDefault(False)
+
         # Start button
         if self.__mode in (TestingWidgetModes.IDLE, TestingWidgetModes.STOPPED):
             self.__startButton.setEnabled(
@@ -573,6 +616,22 @@
         self.progressGroupBox.hide()
         self.tabWidget.setCurrentIndex(0)
 
+        self.raise_()
+        self.activateWindow()
+
+    @pyqtSlot()
+    def __setDiscoverMode(self):
+        """
+        Private slot to switch the widget to test discovery mode.
+        """
+        self.__mode = TestingWidgetModes.DISCOVERY
+
+        self.__totalCount = 0
+
+        self.sbLabel.setText("Discovering Tests")
+        self.tabWidget.setCurrentIndex(0)
+        self.__updateButtonBoxButtons()
+
     @pyqtSlot()
     def __setRunningMode(self):
         """
@@ -628,6 +687,18 @@
 
         self.__resetResults()
 
+        self.discoveryList.clear()
+
+    @pyqtSlot(str)
+    def on_discoveryPicker_editTextChanged(self, txt):
+        """
+        Private slot to handle a change of the discovery start directory.
+
+        @param txt new discovery start directory
+        @type str
+        """
+        self.discoveryList.clear()
+
     @pyqtSlot()
     def on_testsuitePicker_aboutToShowPathPickerDialog(self):
         """
@@ -668,6 +739,8 @@
         @param button button that was clicked
         @type QAbstractButton
         """
+        if button == self.__discoverButton:
+            self.__discoverTests()
         if button == self.__startButton:
             self.startTests()
             self.__saveRecent()
@@ -836,6 +909,46 @@
             )
 
     @pyqtSlot()
+    def __discoverTests(self):
+        """
+        Private slot to discover tests but don't execute them.
+        """
+        if self.__mode in (TestingWidgetModes.RUNNING, TestingWidgetModes.DISCOVERY):
+            return
+
+        self.__recentLog = ""
+
+        environment = self.venvComboBox.currentText()
+        framework = self.frameworkComboBox.currentText()
+
+        discoveryStart = self.discoveryPicker.currentText()
+
+        self.sbLabel.setText(self.tr("Discovering Tests"))
+        QCoreApplication.processEvents()
+
+        interpreter = self.__determineInterpreter(environment)
+        config = TestConfig(
+            interpreter=interpreter,
+            discover=True,
+            discoveryStart=discoveryStart,
+            discoverOnly=True,
+            testNamePattern=self.testNamePatternEdit.text(),
+            testMarkerExpression=self.markerExpressionEdit.text(),
+            failFast=self.failfastCheckBox.isChecked(),
+        )
+
+        self.__testExecutor = self.__frameworkRegistry.createExecutor(framework, self)
+        self.__testExecutor.collected.connect(self.__testsDiscovered)
+        self.__testExecutor.collectError.connect(self.__testDiscoveryError)
+        self.__testExecutor.testFinished.connect(self.__testDiscoveryProcessFinished)
+        self.__testExecutor.discoveryAboutToBeStarted.connect(
+            self.__testDiscoveryAboutToBeStarted
+        )
+
+        self.__setDiscoverMode()
+        self.__testExecutor.discover(config, [])
+
+    @pyqtSlot()
     def startTests(self, failedOnly=False):
         """
         Public slot to start the test run.
@@ -843,7 +956,7 @@
         @param failedOnly flag indicating to run only failed tests
         @type bool
         """
-        if self.__mode == TestingWidgetModes.RUNNING:
+        if self.__mode in (TestingWidgetModes.RUNNING, TestingWidgetModes.DISCOVERY):
             return
 
         self.__recentLog = ""
@@ -880,10 +993,21 @@
         else:
             coverageFile = ""
         interpreter = self.__determineInterpreter(self.__recentEnvironment)
+        testCases = self.__selectedTestCases()
+        if not testCases and self.discoveryList.topLevelItemCount() > 0:
+            ok = EricMessageBox.yesNo(
+                self,
+                self.tr("Running Tests"),
+                self.tr("No test case has been selected. Shall all test cases be run?"),
+            )
+            if not ok:
+                return
+
         config = TestConfig(
             interpreter=interpreter,
             discover=discover,
             discoveryStart=discoveryStart,
+            testCases=testCases,
             testFilename=testFileName,
             testName=testName,
             testNamePattern=self.testNamePatternEdit.text(),
@@ -926,8 +1050,9 @@
         Private slot handling the 'collected' signal of the executor.
 
         @param testNames list of tuples containing the test id, the test name
-            and a description of collected tests
-        @type list of tuple of (str, str, str)
+            a description, the file name, the line number and the test path as a list
+            of collected tests
+        @type list of tuple of (str, str, str, str, int, list)
         """
         testResults = [
             TestResult(
@@ -939,7 +1064,7 @@
                 filename=filename,
                 lineno=lineno,
             )
-            for id, name, desc, filename, lineno in testNames
+            for id, name, desc, filename, lineno, _ in testNames
         ]
         self.__resultsModel.addTestResults(testResults)
         self.__resultsTree.resizeColumns()
@@ -1176,6 +1301,7 @@
             self.testFile.emit(filename, lineno, True)
         else:
             self.__openEditor(filename, lineno)
+            self.__resultsTree.resizeColumns()
 
     def __openEditor(self, filename, linenumber=1):
         """
@@ -1218,18 +1344,190 @@
         @param status selected status
         @type str
         """
-        # TODO: not yet implemented
         if status == self.__allFilter:
             status = ""
 
         self.__resultFilterModel.setStatusFilterString(status)
 
+        if not self.__project:
+            # running in standalone mode
+            self.__resultsTree.resizeColumns()
+
     def __updateStatusFilterComboBox(self):
+        """
+        Private method to update the status filter dialog box.
+        """
         statusFilters = self.__resultsModel.getStatusFilterList()
         self.statusFilterComboBox.clear()
         self.statusFilterComboBox.addItem(self.__allFilter)
         self.statusFilterComboBox.addItems(sorted(statusFilters))
 
+    ############################################################################
+    ## Methods below are handling the discovery only mode.
+    ############################################################################
+
+    def __findDiscoveryItem(self, modulePath):
+        """
+        Private method to find an item given the module path.
+
+        @param modulePath path of the module in dotted notation
+        @type str
+        @return reference to the item or None
+        @rtype QTreeWidgetItem or None
+        """
+        itm = self.discoveryList.topLevelItem(0)
+        while itm is not None:
+            if itm.data(0, TestingWidget.TestCaseNameRole) == modulePath:
+                return itm
+
+            itm = self.discoveryList.itemBelow(itm)
+
+        return None
+
+    @pyqtSlot(list)
+    def __testsDiscovered(self, testNames):
+        """
+        Private slot handling the 'collected' signal of the executor in discovery
+        mode.
+
+        @param testNames list of tuples containing the test id, the test name
+            a description, the file name, the line number and the test path as a list
+            of collected tests
+        @type list of tuple of (str, str, str, str, int, list)
+        """
+        for tid, _name, _desc, filename, lineno, testPath in testNames:
+            parent = None
+            for index in range(1, len(testPath) + 1):
+                modulePath = ".".join(testPath[:index])
+                itm = self.__findDiscoveryItem(modulePath)
+                if itm is not None:
+                    parent = itm
+                else:
+                    if parent is None:
+                        itm = QTreeWidgetItem(self.discoveryList, [testPath[index - 1]])
+                    else:
+                        itm = QTreeWidgetItem(parent, [testPath[index - 1]])
+                        parent.setExpanded(True)
+                    itm.setFlags(itm.flags() | Qt.ItemFlag.ItemIsUserCheckable)
+                    itm.setCheckState(0, Qt.CheckState.Unchecked)
+                    itm.setData(0, TestingWidget.TestCaseNameRole, modulePath)
+                    itm.setData(0, TestingWidget.TestCaseLinenoRole, 0)
+                    if os.path.splitext(os.path.basename(filename))[0] == itm.text(0):
+                        itm.setData(0, TestingWidget.TestCaseFileRole, filename)
+                    elif parent:
+                        fn = parent.data(0, TestingWidget.TestCaseFileRole)
+                        if fn:
+                            itm.setData(0, TestingWidget.TestCaseFileRole, fn)
+                    parent = itm
+
+            if parent:
+                parent.setData(0, TestingWidget.TestCaseLinenoRole, lineno)
+                parent.setData(0, TestingWidget.TestCaseIdRole, tid)
+
+        self.__totalCount += len(testNames)
+
+        self.sbLabel.setText(self.tr("Discovered %n Test(s)", "", self.__totalCount))
+
+    def __testDiscoveryError(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)
+        """
+        for _testFile, error in errors:
+            EricMessageBox.critical(
+                self,
+                self.tr("Discovery Error"),
+                self.tr(
+                    "<p>There was an error while discovering tests in <b>{0}</b>.</p>"
+                    "<p>{1}</p>"
+                ).format("<br/>".join(error.splitlines())),
+            )
+
+    def __testDiscoveryProcessFinished(self, results, output):  # noqa: U100
+        """
+        Private slot to handle the 'testFinished' signal of the executor in
+        discovery mode.
+
+        @param results list of test result objects (if not sent via the
+            'testResult' signal)
+        @type list of TestResult
+        @param output string containing the test process output (if any)
+        @type str
+        """
+        self.__recentLog = output
+
+        self.__setIdleMode()
+
+    def __testDiscoveryAboutToBeStarted(self):
+        """
+        Private slot to handle the 'testDiscoveryAboutToBeStarted' signal of the
+        executor.
+        """
+        self.discoveryList.clear()
+
+    @pyqtSlot(QTreeWidgetItem, int)
+    def on_discoveryList_itemChanged(self, item, column):
+        """
+        Private slot handling the user checking or unchecking an item.
+
+        @param item reference to the item
+        @type QTreeWidgetItem
+        @param column changed column
+        @type int
+        """
+        if column == 0:
+            for index in range(item.childCount()):
+                item.child(index).setCheckState(0, item.checkState(0))
+
+    @pyqtSlot(QTreeWidgetItem, int)
+    def on_discoveryList_itemActivated(self, item, column):
+        """
+        Private slot handling the user activating an item.
+
+        @param item reference to the item
+        @type QTreeWidgetItem
+        @param column column of the double click
+        @type int
+        """
+        if item:
+            filename = item.data(0, TestingWidget.TestCaseFileRole)
+            if filename:
+                self.__showSource(
+                    filename, item.data(0, TestingWidget.TestCaseLinenoRole) + 1
+                )
+
+    def __selectedTestCases(self, parent=None):
+        """
+        Private method to assemble the list of selected test cases and suites.
+
+        @param parent reference to the parent item
+        @type QTreeWidgetItem
+        @return list of selected test cases
+        @rtype list of str
+        """
+        selectedTests = []
+        itemsList = (
+            [
+                # top level
+                self.discoveryList.topLevelItem(index)
+                for index in range(self.discoveryList.topLevelItemCount())
+            ]
+            if parent is None
+            else [parent.child(index) for index in range(parent.childCount())]
+        )
+
+        for itm in itemsList:
+            if itm.checkState(0) == Qt.CheckState.Checked and itm.childCount() == 0:
+                selectedTests.append(itm.data(0, TestingWidget.TestCaseIdRole))
+            if itm.childCount():
+                # recursively check children
+                selectedTests.extend(self.__selectedTestCases(itm))
+
+        return selectedTests
+
 
 class TestingWindow(EricMainWindow):
     """

eric ide

mercurial