Fri, 13 May 2022 17:23:21 +0200
Implemented most of the 'unittest' executor and runner.
# -*- coding: utf-8 -*- # Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de> # """ Module implementing a widget to orchestrate unit test execution. """ import enum import locale 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, ResultCategory ) 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 # TODO: add a "Show Coverage" function using PyCoverageDialog 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.__insertTestFile(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.setEditText(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 __insertTestFile(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 (Start Failed button) # Stop button self.__stopButton.setEnabled( self.__mode == UnittestWidgetModes.RUNNING) self.__stopButton.setDefault( self.__mode == UnittestWidgetModes.RUNNING) # Close button self.buttonBox.button( QDialogButtonBox.StandardButton.Close ).setEnabled(self.__mode in ( UnittestWidgetModes.IDLE, UnittestWidgetModes.STOPPED )) def __updateProgress(self): """ Private method update the progress indicators. """ self.progressCounterRunCount.setText( str(self.__runCount)) self.progressCounterRemCount.setText( str(self.__totalCount - self.__runCount)) self.progressProgressBar.setMaximum(self.__totalCount) self.progressProgressBar.setValue(self.__runCount) def __setIdleMode(self): """ Private method to switch the widget to idle mode. """ self.__mode = UnittestWidgetModes.IDLE self.__updateButtonBoxButtons() self.tabWidget.setCurrentIndex(0) def __setRunningMode(self): """ Private method to switch the widget to running mode. """ self.__mode = UnittestWidgetModes.RUNNING self.__totalCount = 0 self.__runCount = 0 self.__coverageFile = "" # TODO: implement the handling of the 'Show Coverage' button self.sbLabel.setText(self.tr("Running")) self.tabWidget.setCurrentIndex(1) self.__updateButtonBoxButtons() self.__updateProgress() self.__resultsModel.clear() def __setStoppedMode(self): """ Private method to switch the widget to stopped mode. """ self.__mode = UnittestWidgetModes.STOPPED self.__updateButtonBoxButtons() self.raise_() self.activateWindow() @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.__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.__insertTestFile(testFileName) testName = self.testComboBox.currentText() if testName: self.__insertTestName(testName) if testFileName and not testName: testName = "suite" self.sbLabel.setText(self.tr("Preparing Testsuite")) QCoreApplication.processEvents() 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.__testsCollected) self.__testExecutor.collectError.connect(self.__testsCollectError) self.__testExecutor.startTest.connect(self.__testStarted) self.__testExecutor.testResult.connect(self.__processTestResult) self.__testExecutor.testFinished.connect(self.__testProcessFinished) self.__testExecutor.testRunFinished.connect(self.__testRunFinished) self.__testExecutor.stop.connect(self.__testsStopped) self.__testExecutor.coverageDataSaved.connect(self.__coverageData) self.__setRunningMode() self.__testExecutor.start(config, []) @pyqtSlot() def __stopTests(self): """ Private slot to stop the current test run. """ self.__testExecutor.stopIfRunning() @pyqtSlot(list) def __testsCollected(self, testNames): """ Private slot handling the 'collected' signal of the executor. @param testNames list of tuples containing the test id and test name of collected tests @type list of tuple of (str, str) """ testResults = [ UTTestResult( category=ResultCategory.PENDING, status=self.tr("pending"), name=name, id=id, message=desc, ) for id, name, desc in testNames ] self.__resultsModel.setTestResults(testResults) self.__totalCount = len(testResults) self.__updateProgress() @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) """ testResults = [] for testFile, error in errors: if testFile: testResults.append(UTTestResult( category=ResultCategory.FAIL, status=self.tr("Failure"), name=testFile, id=testFile, message=self.tr("Collection Error"), extra=error.splitlines() )) else: EricMessageBox.critical( self, self.tr("Collection Error"), self.tr( "<p>There was an error while collecting unit tests." "</p><p>{0}</p>" ).format("<br/>".join(error.splitlines())) ) if testResults: self.__resultsModel.addTestResults(testResults) @pyqtSlot(tuple) def __testStarted(self, test): """ Private slot handling the 'startTest' signal of the executor. @param test tuple containing the id, name and short description of the tests about to be run @type tuple of (str, str, str) """ self.__resultsModel.updateTestResults([ UTTestResult( category=ResultCategory.RUNNING, status=self.tr("running"), id=test[0], name=test[1], message="" if test[2] is None else test[2], ) ]) @pyqtSlot(UTTestResult) def __processTestResult(self, result): """ Private slot to handle the receipt of a test result object. @param result test result object @type UTTestResult """ self.__runCount += 1 self.__updateProgress() self.__resultsModel.updateTestResults([result]) @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 """ self.__setStoppedMode() self.__testExecutor = None @pyqtSlot(int, float) def __testRunFinished(self, noTests, duration): """ Private slot to handle the 'testRunFinished' signal of the executor. @param noTests number of tests run by the executor @type int @param duration time needed in seconds to run the tests @type float """ self.sbLabel.setText( self.tr("Ran %n test(s) in {0}s", "", noTests).format( locale.format_string("%.3f", duration, grouping=True) ) ) self.__setStoppedMode() @pyqtSlot() def __testsStopped(self): """ Private slot to handle the 'stop' signal of the executor. """ self.sbLabel.setText(self.tr("Ran %n test(s)", "", self.__runCount)) self.__setStoppedMode() @pyqtSlot(str) def __coverageData(self, coverageFile): """ Private slot to handle the 'coverageData' signal of the executor. @param coverageFile file containing the coverage data @type str """ self.__coverageFile = coverageFile # TODO: implement the handling of the 'Show Coverage' button 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()