Implemented the "Show Coverage" functionality and corrected the coverage related code in UnittestRunner. unittest

Tue, 17 May 2022 17:23:07 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Tue, 17 May 2022 17:23:07 +0200
branch
unittest
changeset 9070
eab09a1ab8ce
parent 9069
938039ea15ca
child 9072
8d3ae97ee666

Implemented the "Show Coverage" functionality and corrected the coverage related code in UnittestRunner.

eric7/DataViews/PyCoverageDialog.py file | annotate | diff | comparison | revisions
eric7/Project/Project.py file | annotate | diff | comparison | revisions
eric7/QScintilla/Editor.py file | annotate | diff | comparison | revisions
eric7/Testing/Interfaces/TestExecutorBase.py file | annotate | diff | comparison | revisions
eric7/Testing/Interfaces/UnittestExecutor.py file | annotate | diff | comparison | revisions
eric7/Testing/Interfaces/UnittestRunner.py file | annotate | diff | comparison | revisions
eric7/Testing/TestingWidget.py file | annotate | diff | comparison | revisions
--- a/eric7/DataViews/PyCoverageDialog.py	Tue May 17 14:21:13 2022 +0200
+++ b/eric7/DataViews/PyCoverageDialog.py	Tue May 17 17:23:07 2022 +0200
@@ -11,7 +11,7 @@
 import os
 import time
 
-from PyQt6.QtCore import pyqtSlot, Qt
+from PyQt6.QtCore import pyqtSignal, pyqtSlot, Qt
 from PyQt6.QtWidgets import (
     QDialog, QDialogButtonBox, QMenu, QHeaderView, QTreeWidgetItem,
     QApplication
@@ -31,12 +31,17 @@
 class PyCoverageDialog(QDialog, Ui_PyCoverageDialog):
     """
     Class implementing a dialog to display the collected code coverage data.
+    
+    @signal openFile(str) emitted to open the given file in an editor
     """
+    openFile = pyqtSignal(str)
+    
     def __init__(self, parent=None):
         """
         Constructor
         
-        @param parent parent widget (QWidget)
+        @param parent parent widget
+        @type QWidget
         """
         super().__init__(parent)
         self.setupUi(self)
@@ -80,7 +85,9 @@
         groups.
         
         @param lines list of integers
+        @type list of int
         @return string representing the list
+        @rtype str
         """
         pairs = []
         lines.sort()
@@ -110,6 +117,9 @@
             pair.
             
             @param pair pair of integers
+            @type tuple of (int, int
+            @return representation of the pair
+            @rtype str
             """
             start, end = pair
             if start == end:
@@ -124,12 +134,18 @@
         """
         Private method to create an entry in the result list.
         
-        @param file filename of file (string)
-        @param statements amount of statements (integer)
-        @param executed amount of executed statements (integer)
-        @param coverage percent of coverage (integer)
-        @param excluded list of excluded lines (string)
-        @param missing list of lines without coverage (string)
+        @param file filename of file
+        @type str
+        @param statements number of statements
+        @type int
+        @param executed number of executed statements
+        @type int
+        @param coverage percent of coverage
+        @type int
+        @param excluded list of excluded lines
+        @type str
+        @param missing list of lines without coverage
+        @type str
         """
         itm = QTreeWidgetItem(self.resultList, [
             file,
@@ -146,14 +162,15 @@
             font.setBold(True)
             for col in range(itm.columnCount()):
                 itm.setFont(col, font)
-        
+    
     def start(self, cfn, fn):
         """
         Public slot to start the coverage data evaluation.
         
-        @param cfn basename of the coverage file (string)
+        @param cfn basename of the coverage file
+        @type str
         @param fn file or list of files or directory to be checked
-                (string or list of strings)
+        @type str or list of str
         """
         self.__cfn = cfn
         self.__fn = fn
@@ -251,7 +268,7 @@
                         total_exceptions))
         
         self.__finish()
-        
+    
     def __finish(self):
         """
         Private slot called when the action finished or the user pressed the
@@ -271,12 +288,13 @@
         self.summaryList.header().resizeSections(
             QHeaderView.ResizeMode.ResizeToContents)
         self.summaryList.header().setStretchLastSection(True)
-        
+    
     def on_buttonBox_clicked(self, button):
         """
         Private slot called by a button of the button box clicked.
         
-        @param button button that was clicked (QAbstractButton)
+        @param button button that was clicked
+        @type QAbstractButton
         """
         if button == self.buttonBox.button(
             QDialogButtonBox.StandardButton.Close
@@ -286,12 +304,13 @@
             QDialogButtonBox.StandardButton.Cancel
         ):
             self.__finish()
-        
+    
     def __showContextMenu(self, coord):
         """
         Private slot to show the context menu of the listview.
         
-        @param coord the position of the mouse pointer (QPoint)
+        @param coord position of the mouse pointer
+        @type QPoint
         """
         itm = self.resultList.itemAt(coord)
         if itm:
@@ -301,27 +320,32 @@
             self.annotate.setEnabled(False)
             self.openAct.setEnabled(False)
         self.__menu.popup(self.mapToGlobal(coord))
-        
+    
     def __openFile(self, itm=None):
         """
         Private slot to open the selected file.
         
-        @param itm reference to the item to be opened (QTreeWidgetItem)
+        @param itm reference to the item to be opened
+        @type QTreeWidgetItem
         """
         if itm is None:
             itm = self.resultList.currentItem()
         fn = itm.text(0)
         
-        vm = ericApp().getObject("ViewManager")
-        vm.openSourceFile(fn)
-        editor = vm.getOpenEditor(fn)
-        editor.codeCoverageShowAnnotations()
-        
+        try:
+            vm = ericApp().getObject("ViewManager")
+            vm.openSourceFile(fn)
+            editor = vm.getOpenEditor(fn)
+            editor.codeCoverageShowAnnotations(coverageFile=self.cfn)
+        except KeyError:
+            self.openFile.emit(fn)
+    
+    # TODO: Coverage.annotate is deprecated
     def __annotate(self):
         """
         Private slot to handle the annotate context menu action.
         
-        This method produce an annotated coverage file of the
+        This method produces an annotated coverage file of the
         selected file.
         """
         itm = self.resultList.currentItem()
@@ -331,7 +355,8 @@
         cover.exclude(self.excludeList[0])
         cover.load()
         cover.annotate([fn], None, True)
-        
+    
+    # TODO: Coverage.annotate is deprecated
     def __annotateAll(self):
         """
         Private slot to handle the annotate all context menu action.
@@ -367,7 +392,7 @@
             cover.annotate([file], None)  # , True)
         
         progress.setValue(len(files))
-        
+    
     def __erase(self):
         """
         Private slot to handle the erase context menu action.
@@ -382,7 +407,7 @@
         self.reloadButton.setEnabled(False)
         self.resultList.clear()
         self.summaryList.clear()
-        
+    
     def __deleteAnnotated(self):
         """
         Private slot to handle the delete annotated context menu action.
@@ -394,7 +419,7 @@
         for file in files:
             with contextlib.suppress(OSError):
                 os.remove(file)
-
+    
     @pyqtSlot()
     def on_reloadButton_clicked(self):
         """
--- a/eric7/Project/Project.py	Tue May 17 14:21:13 2022 +0200
+++ b/eric7/Project/Project.py	Tue May 17 17:23:07 2022 +0200
@@ -3363,6 +3363,9 @@
         """
         Public method to return the main script filename.
         
+        The normalized name is the name of the main script prepended with
+        the project path.
+        
         @param normalized flag indicating a normalized filename is wanted
             (boolean)
         @return filename of the projects main script (string)
--- a/eric7/QScintilla/Editor.py	Tue May 17 14:21:13 2022 +0200
+++ b/eric7/QScintilla/Editor.py	Tue May 17 17:23:07 2022 +0200
@@ -522,6 +522,9 @@
         self.autosaveEnabled = Preferences.getEditor("AutosaveInterval") > 0
         self.autosaveManuallyDisabled = False
         
+        # code coverage related attributes
+        self.__coverageFile = ""
+        
         self.__initContextMenu()
         self.__initContextMenuMargins()
         
@@ -5662,6 +5665,8 @@
                 self.isPyFile()
             )
         
+        coEnable |= bool(self.__coverageFile)
+        
         # now check for syntax errors
         if self.hasSyntaxErrors():
             coEnable = False
@@ -6052,6 +6057,10 @@
         """
         files = []
         
+        if bool(self.__coverageFile):
+            # return the path of a previously used coverage file
+            return self.__coverageFile
+        
         # first check if the file belongs to a project and there is
         # a project coverage file
         if (
@@ -6107,6 +6116,7 @@
         Private method to handle the code coverage context menu action.
         """
         fn = self.__getCodeCoverageFile()
+        self.__coverageFile = fn
         if fn:
             from DataViews.PyCoverageDialog import PyCoverageDialog
             self.codecoverage = PyCoverageDialog()
@@ -6120,16 +6130,27 @@
         if self.showingNotcoveredMarkers:
             self.codeCoverageShowAnnotations(silent=True)
         
-    def codeCoverageShowAnnotations(self, silent=False):
+    def codeCoverageShowAnnotations(self, silent=False, coverageFile=None):
         """
         Public method to handle the show code coverage annotations context
         menu action.
         
-        @param silent flag indicating to not show any dialog (boolean)
+        @param silent flag indicating to not show any dialog (defaults to
+            False)
+        @type bool (optional)
+        @param coverageFile path of the file containing the code coverage data
+            (defaults to None)
+        @type str (optional)
         """
         self.__codeCoverageHideAnnotations()
         
-        fn = self.__getCodeCoverageFile()
+        fn = (
+            coverageFile
+            if bool(coverageFile) else
+            self.__getCodeCoverageFile()
+        )
+        self.__coverageFile = fn
+        
         if fn:
             from coverage import Coverage
             cover = Coverage(data_file=fn)
--- a/eric7/Testing/Interfaces/TestExecutorBase.py	Tue May 17 14:21:13 2022 +0200
+++ b/eric7/Testing/Interfaces/TestExecutorBase.py	Tue May 17 17:23:07 2022 +0200
@@ -60,6 +60,7 @@
     failedOnly: bool                # run failed tests only
     collectCoverage: bool           # coverage collection flag
     eraseCoverage: bool             # erase coverage data first
+    coverageFile: str               # name of the coverage data file
 
 
 class TestExecutorBase(QObject):
--- a/eric7/Testing/Interfaces/UnittestExecutor.py	Tue May 17 14:21:13 2022 +0200
+++ b/eric7/Testing/Interfaces/UnittestExecutor.py	Tue May 17 17:23:07 2022 +0200
@@ -109,6 +109,9 @@
             args.append("--cover")
             if config.eraseCoverage:
                 args.append("--cover-erase")
+            if config.coverageFile:
+                args.append("--cover-file")
+                args.append(config.coverageFile)
         
         if config.failedOnly:
             args.append("--failed-only")
--- a/eric7/Testing/Interfaces/UnittestRunner.py	Tue May 17 14:21:13 2022 +0200
+++ b/eric7/Testing/Interfaces/UnittestRunner.py	Tue May 17 17:23:07 2022 +0200
@@ -286,12 +286,18 @@
     if failfast:
         argv.remove("--failfast")
     
-    coverage = "--cover" in argv
-    if coverage:
+    collectCoverage = "--cover" in argv
+    if collectCoverage:
         argv.remove("--cover")
     coverageErase = "--cover-erase" in argv
     if coverageErase:
         argv.remove("--cover-erase")
+    if "--cover-file" in argv:
+        index = argv.index("--cover-file")
+        covDataFile = argv[index + 1]
+        del argv[index:index + 2]
+    else:
+        covDataFile = ""
     
     if argv and argv[0] == "--failed-only":
         if discover:
@@ -315,6 +321,34 @@
     elif discoveryStart:
         sys.path.insert(1, os.path.abspath(discoveryStart))
     
+    # setup test coverage
+    if collectCoverage:
+        if not covDataFile:
+            if discover:
+                covname = os.path.join(discoveryStart, "test")
+            elif testFileName:
+                covname = os.path.splitext(
+                    os.path.abspath(testFileName))[0]
+            else:
+                covname = "test"
+            covDataFile = "{0}.coverage".format(covname)
+        if not os.path.isabs(covDataFile):
+            covDataFile = os.path.abspath(covDataFile)
+        
+        sys.path.insert(
+            2,
+            os.path.abspath(os.path.join(
+                os.path.dirname(__file__), "..", "..", "DebugClients", "Python"
+            ))
+        )
+        from DebugClients.Python.coverage import Coverage
+        cover = Coverage(data_file=covDataFile)
+        if coverageErase:
+            cover.erase()
+        cover.start()
+    else:
+        cover = None
+    
     try:
         testLoader = unittest.TestLoader()
         if discover and not failed:
@@ -354,33 +388,11 @@
     }
     writer.write(collectedTests)
     
-    # setup test coverage
-    if coverage:
-        if discover:
-            covname = os.path.join(discoveryStart, "unittest")
-        elif testFileName:
-            covname = os.path.splitext(
-                os.path.abspath(testFileName))[0]
-        else:
-            covname = "unittest"
-        covDataFile = "{0}.coverage".format(covname)
-        if not os.path.isabs(covDataFile):
-            covDataFile = os.path.abspath(covDataFile)
-        
-        from DebugClients.Python.coverage import coverage as cov
-        cover = cov(data_file=covDataFile)
-        if coverageErase:
-            cover.erase()
-    else:
-        cover = None
-    
     testResult = EricTestResult(writer, failfast)
     startTestRun = getattr(testResult, 'startTestRun', None)
     if startTestRun is not None:
         startTestRun()
     try:
-        if cover:
-            cover.start()
         test.run(testResult)
     finally:
         if cover:
--- a/eric7/Testing/TestingWidget.py	Tue May 17 14:21:13 2022 +0200
+++ b/eric7/Testing/TestingWidget.py	Tue May 17 17:23:07 2022 +0200
@@ -50,8 +50,6 @@
     STOPPED = 2         # test run finished
 
 
-# TODO: add a "Show Coverage" function using PyCoverageDialog
-
 class TestingWidget(QWidget, Ui_TestingWidget):
     """
     Class implementing a widget to orchestrate unit test execution.
@@ -102,6 +100,16 @@
         self.testComboBox.lineEdit().setClearButtonEnabled(True)
         
         # create some more dialog buttons for orchestration
+        self.__showCoverageButton = self.buttonBox.addButton(
+            self.tr("Show Coverage..."),
+            QDialogButtonBox.ButtonRole.ActionRole)
+        self.__showCoverageButton.setToolTip(
+            self.tr("Show code coverage in a new dialog"))
+        self.__showCoverageButton.setWhatsThis(self.tr(
+            """<b>Show Coverage...</b>"""
+            """<p>This button opens a dialog containing the collected code"""
+            """ coverage data.</p>"""))
+        
         self.__startButton = self.buttonBox.addButton(
             self.tr("Start"), QDialogButtonBox.ButtonRole.ActionRole)
         
@@ -160,6 +168,9 @@
         self.__recentEnvironment = ""
         self.__failedTests = []
         
+        self.__coverageFile = ""
+        self.__coverageDialog = None
+        
         self.__editors = []
         self.__testExecutor = None
         
@@ -477,6 +488,17 @@
         self.__stopButton.setDefault(
             self.__mode == TestingWidgetModes.RUNNING)
         
+        # Code coverage button
+        self.__showCoverageButton.setEnabled(
+            self.__mode == TestingWidgetModes.STOPPED and
+            bool(self.__coverageFile) and
+                (
+                    (self.discoverCheckBox.isChecked() and
+                     bool(self.discoveryPicker.currentText())) or
+                    bool(self.testsuitePicker.currentText())
+                )
+        )
+        
         # Close button
         self.buttonBox.button(
             QDialogButtonBox.StandardButton.Close
@@ -517,7 +539,6 @@
         self.__runCount = 0
         
         self.__coverageFile = ""
-        # TODO: implement the handling of the 'Show Coverage' button
         
         self.sbLabel.setText(self.tr("Running"))
         self.tabWidget.setCurrentIndex(1)
@@ -605,6 +626,8 @@
             self.__stopTests()
         elif button == self.__startFailedButton:
             self.startTests(failedOnly=True)
+        elif button == self.__showCoverageButton:
+            self.__showCoverageDialog()
     
     @pyqtSlot(int)
     def on_venvComboBox_currentIndexChanged(self, index):
@@ -703,11 +726,16 @@
         self.sbLabel.setText(self.tr("Preparing Testsuite"))
         QCoreApplication.processEvents()
         
+        if self.__project:
+            mainScript = self.__project.getMainScript(True)
+            coverageFile = os.path.splitext(mainScript)[0] + ".coverage"
+        else:
+            coverageFile = ""
         interpreter = self.__venvManager.getVirtualenvInterpreter(
             self.__recentEnvironment)
         config = TestConfig(
             interpreter=interpreter,
-            discover=self.discoverCheckBox.isChecked(),
+            discover=discover,
             discoveryStart=discoveryStart,
             testFilename=testFileName,
             testName=testName,
@@ -715,6 +743,7 @@
             failedOnly=failedOnly,
             collectCoverage=self.coverageCheckBox.isChecked(),
             eraseCoverage=self.coverageEraseCheckBox.isChecked(),
+            coverageFile=coverageFile,
         )
         
         self.__testExecutor = self.__frameworkRegistry.createExecutor(
@@ -888,8 +917,25 @@
         @type str
         """
         self.__coverageFile = coverageFile
+    
+    @pyqtSlot()
+    def __showCoverageDialog(self):
+        """
+        Private slot to show a code coverage dialog for the most recent test
+        run.
+        """
+        if self.__coverageDialog is None:
+            from DataViews.PyCoverageDialog import PyCoverageDialog
+            self.__coverageDialog = PyCoverageDialog(self)
+            self.__coverageDialog.openFile.connect(self.__openEditor)
         
-        # TODO: implement the handling of the 'Show Coverage' button
+        if self.discoverCheckBox.isChecked():
+            testDir = self.discoveryPicker.currentText()
+        else:
+            testDir = os.path.dirname(self.testsuitePicker.currentText())
+        if testDir:
+            self.__coverageDialog.show()
+            self.__coverageDialog.start(self.__coverageFile, testDir)
     
     @pyqtSlot(str)
     def __setStatusLabel(self, statusText):
@@ -936,7 +982,7 @@
         else:
             self.__openEditor(filename, lineno)
     
-    def __openEditor(self, filename, linenumber):
+    def __openEditor(self, filename, linenumber=1):
         """
         Private method to open an editor window for the given file.
         
@@ -945,8 +991,8 @@
         
         @param filename path of the file to be opened
         @type str
-        @param linenumber line number to place the cursor at
-        @type int
+        @param linenumber line number to place the cursor at (defaults to 1)
+        @type int (optional)
         """
         from QScintilla.MiniEditor import MiniEditor
         editor = MiniEditor(filename, "Python3", self)

eric ide

mercurial