--- 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): """