diff -r 411df92e881f -r 3b34efa2857c src/eric7/Testing/TestingWidget.py --- a/src/eric7/Testing/TestingWidget.py Sun Dec 03 14:54:00 2023 +0100 +++ b/src/eric7/Testing/TestingWidget.py Mon Jan 01 11:10:45 2024 +0100 @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2022 - 2023 Detlev Offenbach <detlev@die-offenbachs.de> +# Copyright (c) 2022 - 2024 Detlev Offenbach <detlev@die-offenbachs.de> # """ @@ -12,8 +12,15 @@ import locale import os -from PyQt6.QtCore import QCoreApplication, QEvent, Qt, pyqtSignal, pyqtSlot -from PyQt6.QtWidgets import QAbstractButton, QComboBox, QDialogButtonBox, QWidget +from PyQt6.QtCore import QCoreApplication, QEvent, QPoint, Qt, pyqtSignal, pyqtSlot +from PyQt6.QtWidgets import ( + QAbstractButton, + QComboBox, + QDialogButtonBox, + QMenu, + QTreeWidgetItem, + QWidget, +) from eric7 import Preferences from eric7.DataViews.PyCoverageDialog import PyCoverageDialog @@ -33,7 +40,11 @@ from .Interfaces import Frameworks from .Interfaces.TestExecutorBase import TestConfig, TestResult, TestResultCategory from .Interfaces.TestFrameworkRegistry import TestFrameworkRegistry -from .TestResultsTree import TestResultsModel, TestResultsTreeView +from .TestResultsTree import ( + TestResultsFilterModel, + TestResultsModel, + TestResultsTreeView, +) from .Ui_TestingWidget import Ui_TestingWidget @@ -45,6 +56,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 +71,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 @@ -73,8 +90,10 @@ self.__resultsModel = TestResultsModel(self) self.__resultsModel.summary.connect(self.__setStatusLabel) + self.__resultFilterModel = TestResultsFilterModel(self) + self.__resultFilterModel.setSourceModel(self.__resultsModel) self.__resultsTree = TestResultsTreeView(self) - self.__resultsTree.setModel(self.__resultsModel) + self.__resultsTree.setModel(self.__resultFilterModel) self.__resultsTree.goto.connect(self.__showSource) self.resultsGroupBox.layout().addWidget(self.__resultsTree) @@ -99,6 +118,8 @@ ) self.testComboBox.lineEdit().setClearButtonEnabled(True) + self.__allFilter = self.tr("<all>") + # create some more dialog buttons for orchestration self.__showLogButton = self.buttonBox.addButton( self.tr("Show Output..."), QDialogButtonBox.ButtonRole.ActionRole @@ -128,11 +149,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>""") ) @@ -171,7 +203,6 @@ self.__project = ericApp().getObject("Project") self.__project.projectOpened.connect(self.__projectOpened) self.__project.projectClosed.connect(self.__projectClosed) - self.__projectEnvironmentMarker = self.tr("<project>") except KeyError: # we were called as a standalone application from eric7.VirtualEnv.VirtualenvManager import ( # __IGNORE_WARNING_I101__ @@ -191,7 +222,9 @@ ericApp().registerObject("VirtualEnvManager", self.__venvManager) self.__project = None - self.__projectEnvironmentMarker = "" + + self.debuggerCheckBox.setChecked(False) + self.debuggerCheckBox.setVisible(False) self.__discoverHistory = [] self.__fileHistory = [] @@ -206,9 +239,18 @@ self.__editors = [] self.__testExecutor = None self.__recentLog = "" + self.__projectString = "" self.__markersWindow = None + self.__discoveryListContextMenu = QMenu(self.discoveryList) + self.__discoveryListContextMenu.addAction( + self.tr("Collapse All"), self.discoveryList.collapseAll + ) + self.__discoveryListContextMenu.addAction( + self.tr("Expand All"), self.discoveryList.expandAll + ) + # connect some signals self.discoveryPicker.editTextChanged.connect(self.__resetResults) self.testsuitePicker.editTextChanged.connect(self.__resetResults) @@ -248,7 +290,10 @@ @return path of the interpreter executable @rtype str """ - if self.__project and venvName == self.__projectEnvironmentMarker: + if ( + self.__project + and venvName == ericApp().getObject("DebugUI").getProjectEnvironmentString() + ): return self.__project.getProjectInterpreter() else: return self.__venvManager.getVirtualenvInterpreter(venvName) @@ -264,7 +309,10 @@ self.venvComboBox.clear() self.venvComboBox.addItem("") if self.__project and self.__project.isOpen(): - self.venvComboBox.addItem(self.__projectEnvironmentMarker) + venvName = ericApp().getObject("DebugUI").getProjectEnvironmentString() + if venvName: + self.venvComboBox.addItem(venvName) + self.__projectString = venvName self.venvComboBox.addItems(sorted(self.__venvManager.getVirtualenvNames())) self.venvComboBox.setCurrentText(currentText) @@ -333,15 +381,16 @@ @param item item to be inserted @type str """ - # 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) - widget.setEditText(item) + if history and item != history[0]: + # prepend the given directory to the given widget + if item is None: + item = "" + if item in history: + history.remove(item) + history.insert(0, item) + widget.clear() + widget.addItems(history) + widget.setEditText(item) @pyqtSlot(str) def __insertDiscovery(self, start): @@ -496,6 +545,18 @@ """ 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( @@ -569,6 +630,21 @@ 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.tabWidget.setCurrentIndex(0) + self.__updateButtonBoxButtons() + @pyqtSlot() def __setRunningMode(self): """ @@ -624,6 +700,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): """ @@ -664,13 +752,15 @@ @param button button that was clicked @type QAbstractButton """ + if button == self.__discoverButton: + self.__discoverTests() if button == self.__startButton: - self.startTests() + self.startTests(debug=self.debuggerCheckBox.isChecked()) self.__saveRecent() elif button == self.__stopButton: self.__stopTests() elif button == self.__startFailedButton: - self.startTests(failedOnly=True) + self.startTests(failedOnly=True, debug=self.debuggerCheckBox.isChecked()) elif button == self.__showCoverageButton: self.__showCoverageDialog() elif button == self.__showLogButton: @@ -685,6 +775,8 @@ @type int """ self.__populateTestFrameworkComboBox() + self.discoveryList.clear() + self.__updateButtonBoxButtons() self.versionsButton.setEnabled(bool(self.venvComboBox.currentText())) @@ -703,6 +795,7 @@ self.__updateCoverage() self.__updateMarkerSupport() self.__updatePatternSupport() + self.discoveryList.clear() @pyqtSlot() def __updateCoverage(self): @@ -805,7 +898,7 @@ headerText = self.tr("<h3>Versions of Frameworks and their Plugins</h3>") versionsText = "" interpreter = self.__determineInterpreter(venvName) - for framework in sorted(self.__frameworkRegistry.getFrameworks().keys()): + for framework in sorted(self.__frameworkRegistry.getFrameworks()): executor = self.__frameworkRegistry.createExecutor(framework, self) versions = executor.getVersions(interpreter) if versions: @@ -832,14 +925,59 @@ ) @pyqtSlot() - def startTests(self, failedOnly=False): + 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() + if discoveryStart: + self.__insertDiscovery(discoveryStart) + + 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, debug=False): """ Public slot to start the test run. - @param failedOnly flag indicating to run only failed tests - @type bool + @param failedOnly flag indicating to run only failed tests (defaults to False) + @type bool (optional) + @param debug flag indicating to start the test run with debugger support + (defaults to False) + @type bool (optional) """ - if self.__mode == TestingWidgetModes.RUNNING: + if self.__mode in (TestingWidgetModes.RUNNING, TestingWidgetModes.DISCOVERY): return self.__recentLog = "" @@ -876,10 +1014,22 @@ 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(), @@ -889,6 +1039,7 @@ collectCoverage=self.coverageCheckBox.isChecked(), eraseCoverage=self.coverageEraseCheckBox.isChecked(), coverageFile=coverageFile, + venvName=self.__recentEnvironment, ) self.__testExecutor = self.__frameworkRegistry.createExecutor( @@ -907,7 +1058,10 @@ ) self.__setRunningMode() - self.__testExecutor.start(config, []) + if debug: + self.__testExecutor.startDebug(config, [], ericApp().getObject("DebugUI")) + else: + self.__testExecutor.start(config, []) @pyqtSlot() def __stopTests(self): @@ -922,8 +1076,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( @@ -932,8 +1087,10 @@ name=name, id=id, message=desc, + filename=filename, + lineno=lineno, ) - for id, name, desc in testNames + for id, name, desc, filename, lineno, _ in testNames ] self.__resultsModel.addTestResults(testResults) self.__resultsTree.resizeColumns() @@ -1029,6 +1186,7 @@ self.__testExecutor = None self.__adjustPendingState() + self.__updateStatusFilterComboBox() @pyqtSlot(int, float) def __testRunFinished(self, noTests, duration): @@ -1064,6 +1222,7 @@ executor. """ self.__resultsModel.clear() + self.statusFilterComboBox.clear() def __adjustPendingState(self): """ @@ -1136,8 +1295,20 @@ """ Private slot to handle a project being opened. """ - self.venvComboBox.insertItem(1, self.__projectEnvironmentMarker) - self.venvComboBox.setCurrentIndex(1) + self.__projectString = ( + ericApp().getObject("DebugUI").getProjectEnvironmentString() + ) + + if self.__projectString: + # 1a. remove old project venv entries + while (row := self.venvComboBox.findText(self.__projectString)) != -1: + self.venvComboBox.removeItem(row) + + # 1b. add a new project venv entry + self.venvComboBox.insertItem(1, self.__projectString) + self.venvComboBox.setCurrentIndex(1) + + # 2. set some other project related stuff self.frameworkComboBox.setCurrentText( self.__project.getProjectTestingFramework() ) @@ -1148,11 +1319,18 @@ """ Private slot to handle a project being closed. """ - self.venvComboBox.removeItem(1) # <project> is always at index 1 - self.venvComboBox.setCurrentText("") + if self.__projectString: + while (row := self.venvComboBox.findText(self.__projectString)) != -1: + self.venvComboBox.removeItem(row) + + self.venvComboBox.setCurrentText("") + self.frameworkComboBox.setCurrentText("") self.__insertDiscovery("") + # clear latest log assuming it was for a project test run + self.__recentLog = "" + @pyqtSlot(str, int) def __showSource(self, filename, lineno): """ @@ -1168,6 +1346,7 @@ self.testFile.emit(filename, lineno, True) else: self.__openEditor(filename, lineno) + self.__resultsTree.resizeColumns() def __openEditor(self, filename, linenumber=1): """ @@ -1202,6 +1381,213 @@ with contextlib.suppress(RuntimeError): editor.close() + @pyqtSlot(str) + def on_statusFilterComboBox_currentTextChanged(self, status): + """ + Private slot handling the selection of a status for items to be shown. + + @param status selected status + @type str + """ + 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( + self.discoveryPicker.currentText(), + "<br/>".join(error.splitlines()), + ), + ) + self.sbLabel.clear() + + 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.discoveryList.sortItems(0, Qt.SortOrder.AscendingOrder) + + 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 + + @pyqtSlot(QPoint) + def on_discoveryList_customContextMenuRequested(self, pos): + """ + Private slot to show the context menu of the dicovery list. + + @param pos the position of the mouse pointer + @type QPoint + """ + self.__discoveryListContextMenu.exec(self.discoveryList.mapToGlobal(pos)) + class TestingWindow(EricMainWindow): """ @@ -1235,9 +1621,12 @@ """ 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) + @param obj reference to the object the event is meant for + @type QObject + @param event reference to the event object + @type QEvent + @return flag indicating, whether the event was handled + @rtype bool """ if event.type() == QEvent.Type.Close: QCoreApplication.exit(0)