eric7/Testing/TestResultsTree.py

branch
unittest
changeset 9066
a219ade50f7c
parent 9065
39405e6eba20
child 9160
1675c039a568
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric7/Testing/TestResultsTree.py	Mon May 16 19:46:51 2022 +0200
@@ -0,0 +1,611 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing a tree view and associated model to show the test result
+data.
+"""
+
+import contextlib
+import copy
+import locale
+
+from collections import Counter
+from operator import attrgetter
+
+from PyQt6.QtCore import (
+    pyqtSignal, pyqtSlot, Qt, QAbstractItemModel, QCoreApplication,
+    QModelIndex, QPoint
+)
+from PyQt6.QtGui import QBrush, QColor
+from PyQt6.QtWidgets import QMenu, QTreeView
+
+from EricWidgets.EricApplication import ericApp
+
+import Preferences
+
+from .Interfaces.TestExecutorBase import TestResultCategory
+
+TopLevelId = 2 ** 32 - 1
+
+
+class TestResultsModel(QAbstractItemModel):
+    """
+    Class implementing the item model containing the test data.
+    
+    @signal summary(str) emitted whenever the model data changes. The element
+        is a summary of the test results of the model.
+    """
+    summary = pyqtSignal(str)
+    
+    Headers = [
+        QCoreApplication.translate("TestResultsModel", "Status"),
+        QCoreApplication.translate("TestResultsModel", "Name"),
+        QCoreApplication.translate("TestResultsModel", "Message"),
+        QCoreApplication.translate("TestResultsModel", "Duration [ms]"),
+    ]
+    
+    StatusColumn = 0
+    NameColumn = 1
+    MessageColumn = 2
+    DurationColumn = 3
+    
+    def __init__(self, parent=None):
+        """
+        Constructor
+        
+        @param parent reference to the parent object (defaults to None)
+        @type QObject (optional)
+        """
+        super().__init__(parent)
+        
+        if ericApp().usesDarkPalette():
+            self.__backgroundColors = {
+                TestResultCategory.RUNNING: None,
+                TestResultCategory.FAIL: QBrush(QColor("#880000")),
+                TestResultCategory.OK: QBrush(QColor("#005500")),
+                TestResultCategory.SKIP: QBrush(QColor("#3f3f3f")),
+                TestResultCategory.PENDING: QBrush(QColor("#004768")),
+            }
+        else:
+            self.__backgroundColors = {
+                TestResultCategory.RUNNING: None,
+                TestResultCategory.FAIL: QBrush(QColor("#ff8080")),
+                TestResultCategory.OK: QBrush(QColor("#c1ffba")),
+                TestResultCategory.SKIP: QBrush(QColor("#c5c5c5")),
+                TestResultCategory.PENDING: QBrush(QColor("#6fbaff")),
+            }
+        
+        self.__testResults = []
+    
+    def index(self, row, column, parent=QModelIndex()):
+        """
+        Public method to generate an index for the given row and column to
+        identify the item.
+        
+        @param row row for the index
+        @type int
+        @param column column for the index
+        @type int
+        @param parent index of the parent item (defaults to QModelIndex())
+        @type QModelIndex (optional)
+        @return index for the item
+        @rtype QModelIndex
+        """
+        if not self.hasIndex(row, column, parent):  # check bounds etc.
+            return QModelIndex()
+        
+        if not parent.isValid():
+            # top level item
+            return self.createIndex(row, column, TopLevelId)
+        else:
+            testResultIndex = parent.row()
+            return self.createIndex(row, column, testResultIndex)
+    
+    def data(self, index, role):
+        """
+        Public method to get the data for the various columns and roles.
+        
+        @param index index of the data to be returned
+        @type QModelIndex
+        @param role role designating the data to return
+        @type Qt.ItemDataRole
+        @return requested data item
+        @rtype Any
+        """
+        if not index.isValid():
+            return None
+        
+        row = index.row()
+        column = index.column()
+        idx = index.internalId()
+        
+        if role == Qt.ItemDataRole.DisplayRole:
+            if idx != TopLevelId:
+                if bool(self.__testResults[idx].extra):
+                    return self.__testResults[idx].extra[index.row()]
+                else:
+                    return None
+            elif column == TestResultsModel.StatusColumn:
+                return self.__testResults[row].status
+            elif column == TestResultsModel.NameColumn:
+                return self.__testResults[row].name
+            elif column == TestResultsModel.MessageColumn:
+                return self.__testResults[row].message
+            elif column == TestResultsModel.DurationColumn:
+                duration = self.__testResults[row].duration
+                return (
+                    ""
+                    if duration is None else
+                    locale.format_string("%.2f", duration, grouping=True)
+                )
+        elif role == Qt.ItemDataRole.ToolTipRole:
+            if idx == TopLevelId and column == TestResultsModel.NameColumn:
+                return self.__testResults[row].name
+        elif role == Qt.ItemDataRole.FontRole:
+            if idx != TopLevelId:
+                return Preferences.getEditorOtherFonts("MonospacedFont")
+        elif role == Qt.ItemDataRole.BackgroundRole:
+            if idx == TopLevelId:
+                testResult = self.__testResults[row]
+                with contextlib.suppress(KeyError):
+                    return self.__backgroundColors[testResult.category]
+        elif role == Qt.ItemDataRole.TextAlignmentRole:
+            if idx == TopLevelId and column == TestResultsModel.DurationColumn:
+                return Qt.AlignmentFlag.AlignRight
+        elif role == Qt.ItemDataRole.UserRole:      # __IGNORE_WARNING_Y102__
+            if idx == TopLevelId:
+                testresult = self.__testResults[row]
+                return (testresult.filename, testresult.lineno)
+        
+        return None
+    
+    def headerData(self, section, orientation,
+                   role=Qt.ItemDataRole.DisplayRole):
+        """
+        Public method to get the header string for the various sections.
+        
+        @param section section number
+        @type int
+        @param orientation orientation of the header
+        @type Qt.Orientation
+        @param role data role (defaults to Qt.ItemDataRole.DisplayRole)
+        @type Qt.ItemDataRole (optional)
+        @return header string of the section
+        @rtype str
+        """
+        if (
+            orientation == Qt.Orientation.Horizontal and
+            role == Qt.ItemDataRole.DisplayRole
+        ):
+            return TestResultsModel.Headers[section]
+        else:
+            return None
+    
+    def parent(self, index):
+        """
+        Public method to get the parent of the item pointed to by index.
+        
+        @param index index of the item
+        @type QModelIndex
+        @return index of the parent item
+        @rtype QModelIndex
+        """
+        if not index.isValid():
+            return QModelIndex()
+        
+        idx = index.internalId()
+        if idx == TopLevelId:
+            return QModelIndex()
+        else:
+            return self.index(idx, 0)
+    
+    def rowCount(self, parent=QModelIndex()):
+        """
+        Public method to get the number of row for a given parent index.
+        
+        @param parent index of the parent item (defaults to QModelIndex())
+        @type QModelIndex (optional)
+        @return number of rows
+        @rtype int
+        """
+        if not parent.isValid():
+            return len(self.__testResults)
+        
+        if (
+            parent.internalId() == TopLevelId and
+            parent.column() == 0 and
+            self.__testResults[parent.row()].extra is not None
+        ):
+            return len(self.__testResults[parent.row()].extra)
+        
+        return 0
+
+    def columnCount(self, parent=QModelIndex()):
+        """
+        Public method to get the number of columns.
+        
+        @param parent index of the parent item (defaults to QModelIndex())
+        @type QModelIndex (optional)
+        @return number of columns
+        @rtype int
+        """
+        if not parent.isValid():
+            return len(TestResultsModel.Headers)
+        else:
+            return 1
+    
+    def clear(self):
+        """
+        Public method to clear the model data.
+        """
+        self.beginResetModel()
+        self.__testResults.clear()
+        self.endResetModel()
+        
+        self.summary.emit("")
+    
+    def sort(self, column, order):
+        """
+        Public method to sort the model data by column in order.
+        
+        @param column sort column number
+        @type int
+        @param order sort order
+        @type Qt.SortOrder
+        """             # __IGNORE_WARNING_D234r__
+        def durationKey(result):
+            """
+            Function to generate a key for duration sorting
+            
+            @param result result object
+            @type TestResult
+            @return sort key
+            @rtype float
+            """
+            return result.duration or -1.0
+
+        self.beginResetModel()
+        reverse = order == Qt.SortOrder.DescendingOrder
+        if column == TestResultsModel.StatusColumn:
+            self.__testResults.sort(key=attrgetter('category', 'status'),
+                                    reverse=reverse)
+        elif column == TestResultsModel.NameColumn:
+            self.__testResults.sort(key=attrgetter('name'), reverse=reverse)
+        elif column == TestResultsModel.MessageColumn:
+            self.__testResults.sort(key=attrgetter('message'), reverse=reverse)
+        elif column == TestResultsModel.DurationColumn:
+            self.__testResults.sort(key=durationKey, reverse=reverse)
+        self.endResetModel()
+    
+    def getTestResults(self):
+        """
+        Public method to get the list of test results managed by the model.
+        
+        @return list of test results managed by the model
+        @rtype list of TestResult
+        """
+        return copy.deepcopy(self.__testResults)
+    
+    def setTestResults(self, testResults):
+        """
+        Public method to set the list of test results of the model.
+        
+        @param testResults test results to be managed by the model
+        @type list of TestResult
+        """
+        self.beginResetModel()
+        self.__testResults = copy.deepcopy(testResults)
+        self.endResetModel()
+        
+        self.summary.emit(self.__summary())
+    
+    def addTestResults(self, testResults):
+        """
+        Public method to add test results to the ones already managed by the
+        model.
+        
+        @param testResults test results to be added to the model
+        @type list of TestResult
+        """
+        firstRow = len(self.__testResults)
+        lastRow = firstRow + len(testResults) - 1
+        self.beginInsertRows(QModelIndex(), firstRow, lastRow)
+        self.__testResults.extend(testResults)
+        self.endInsertRows()
+        
+        self.summary.emit(self.__summary())
+    
+    def updateTestResults(self, testResults):
+        """
+        Public method to update the data of managed test result items.
+        
+        @param testResults test results to be updated
+        @type list of TestResult
+        """
+        minIndex = None
+        maxIndex = None
+        
+        testResultsToBeAdded = []
+        
+        for testResult in testResults:
+            for (index, currentResult) in enumerate(self.__testResults):
+                if currentResult.id == testResult.id:
+                    self.__testResults[index] = testResult
+                    if minIndex is None:
+                        minIndex = index
+                        maxIndex = index
+                    else:
+                        minIndex = min(minIndex, index)
+                        maxIndex = max(maxIndex, index)
+                    
+                    break
+            else:
+                # Test result with given id was not found.
+                # Just add it to the list (could be a sub test)
+                testResultsToBeAdded.append(testResult)
+        
+        if minIndex is not None:
+            self.dataChanged.emit(
+                self.index(minIndex, 0),
+                self.index(maxIndex, len(TestResultsModel.Headers) - 1)
+            )
+            
+            self.summary.emit(self.__summary())
+        
+        if testResultsToBeAdded:
+            self.addTestResults(testResultsToBeAdded)
+    
+    def getFailedTests(self):
+        """
+        Public method to extract the test ids of all failed tests.
+        
+        @return test ids of all failed tests
+        @rtype list of str
+        """
+        failedIds = [
+            res.id for res in self.__testResults if (
+                res.category == TestResultCategory.FAIL and
+                not res.subtestResult
+            )
+        ]
+        return failedIds
+    
+    def __summary(self):
+        """
+        Private method to generate a test results summary text.
+        
+        @return test results summary text
+        @rtype str
+        """
+        if len(self.__testResults) == 0:
+            return self.tr("No results to show")
+        
+        counts = Counter(res.category for res in self.__testResults)
+        if all(
+            counts[category] == 0
+            for category in (TestResultCategory.FAIL, TestResultCategory.OK,
+                             TestResultCategory.SKIP)
+        ):
+            return self.tr("Collected %n test(s)", "", len(self.__testResults))
+        
+        return self.tr(
+            "%n test(s)/subtest(s) total, {0} failed, {1} passed,"
+            " {2} skipped, {3} pending",
+            "", len(self.__testResults)
+        ).format(
+            counts[TestResultCategory.FAIL],
+            counts[TestResultCategory.OK],
+            counts[TestResultCategory.SKIP],
+            counts[TestResultCategory.PENDING]
+        )
+
+
+class TestResultsTreeView(QTreeView):
+    """
+    Class implementing a tree view to show the test result data.
+    
+    @signal goto(str, int) emitted to go to the position given by file name
+        and line number
+    """
+    goto = pyqtSignal(str, int)
+    
+    def __init__(self, parent=None):
+        """
+        Constructor
+        
+        @param parent reference to the parent widget (defaults to None)
+        @type QWidget (optional)
+        """
+        super().__init__(parent)
+        
+        self.setItemsExpandable(True)
+        self.setExpandsOnDoubleClick(False)
+        self.setSortingEnabled(True)
+        
+        self.header().setDefaultAlignment(Qt.AlignmentFlag.AlignCenter)
+        self.header().setSortIndicatorShown(False)
+        
+        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
+        
+        # connect signals and slots
+        self.doubleClicked.connect(self.__gotoTestDefinition)
+        self.customContextMenuRequested.connect(self.__showContextMenu)
+        
+        self.header().sortIndicatorChanged.connect(self.sortByColumn)
+        self.header().sortIndicatorChanged.connect(
+            lambda column, order: self.header().setSortIndicatorShown(True))
+    
+    def reset(self):
+        """
+        Public method to reset the internal state of the view.
+        """
+        super().reset()
+        
+        self.resizeColumns()
+        self.spanFirstColumn(0, self.model().rowCount() - 1)
+    
+    def rowsInserted(self, parent, startRow, endRow):
+        """
+        Public method called when rows are inserted.
+        
+        @param parent model index of the parent item
+        @type QModelIndex
+        @param startRow first row been inserted
+        @type int
+        @param endRow last row been inserted
+        @type int
+        """
+        super().rowsInserted(parent, startRow, endRow)
+        
+        self.resizeColumns()
+        self.spanFirstColumn(startRow, endRow)
+    
+    def dataChanged(self, topLeft, bottomRight, roles=[]):
+        """
+        Public method called when the model data has changed.
+        
+        @param topLeft index of the top left element
+        @type QModelIndex
+        @param bottomRight index of the bottom right element
+        @type QModelIndex
+        @param roles list of roles changed (defaults to [])
+        @type list of Qt.ItemDataRole (optional)
+        """
+        super().dataChanged(topLeft, bottomRight, roles)
+        
+        self.resizeColumns()
+        while topLeft.parent().isValid():
+            topLeft = topLeft.parent()
+        while bottomRight.parent().isValid():
+            bottomRight = bottomRight.parent()
+        self.spanFirstColumn(topLeft.row(), bottomRight.row())
+    
+    def resizeColumns(self):
+        """
+        Public method to resize the columns to their contents.
+        """
+        for column in range(self.model().columnCount()):
+            self.resizeColumnToContents(column)
+    
+    def spanFirstColumn(self, startRow, endRow):
+        """
+        Public method to make the first column span the row for second level
+        items.
+        
+        These items contain the test results.
+        
+        @param startRow index of the first row to span
+        @type QModelIndex
+        @param endRow index of the last row (including) to span
+        @type QModelIndex
+        """
+        model = self.model()
+        for row in range(startRow, endRow + 1):
+            index = model.index(row, 0)
+            for i in range(model.rowCount(index)):
+                self.setFirstColumnSpanned(i, index, True)
+    
+    def __canonicalIndex(self, index):
+        """
+        Private method to create the canonical index for a given index.
+        
+        The canonical index is the index of the first column of the test
+        result entry (i.e. the top-level item). If the index is invalid,
+        None is returned.
+        
+        @param index index to determine the canonical index for
+        @type QModelIndex
+        @return index of the firt column of the associated top-level item index
+        @rtype QModelIndex
+        """
+        if not index.isValid():
+            return None
+        
+        while index.parent().isValid():  # find the top-level node
+            index = index.parent()
+        index = index.sibling(index.row(), 0)  # go to first column
+        return index
+    
+    @pyqtSlot(QModelIndex)
+    def __gotoTestDefinition(self, index):
+        """
+        Private slot to show the test definition.
+        
+        @param index index for the double-clicked item
+        @type QModelIndex
+        """
+        cindex = self.__canonicalIndex(index)
+        filename, lineno = self.model().data(cindex, Qt.ItemDataRole.UserRole)
+        if filename is not None:
+            if lineno is None:
+                lineno = 1
+            self.goto.emit(filename, lineno)
+    
+    @pyqtSlot(QPoint)
+    def __showContextMenu(self, pos):
+        """
+        Private slot to show the context menu.
+        
+        @param pos relative position for the context menu
+        @type QPoint
+        """
+        index = self.indexAt(pos)
+        cindex = self.__canonicalIndex(index)
+        
+        contextMenu = (
+            self.__createContextMenu(cindex)
+            if cindex else
+            self.__createBackgroundContextMenu()
+        )
+        contextMenu.exec(self.mapToGlobal(pos))
+    
+    def __createContextMenu(self, index):
+        """
+        Private method to create a context menu for the item pointed to by the
+        given index.
+        
+        @param index index of the item
+        @type QModelIndex
+        @return created context menu
+        @rtype QMenu
+        """
+        menu = QMenu(self)
+        if self.isExpanded(index):
+            menu.addAction(self.tr("Collapse"),
+                           lambda: self.collapse(index))
+        else:
+            act = menu.addAction(self.tr("Expand"),
+                                 lambda: self.expand(index))
+            act.setEnabled(self.model().hasChildren(index))
+        menu.addSeparator()
+        
+        act = menu.addAction(self.tr("Show Source"),
+                             lambda: self.__gotoTestDefinition(index))
+        act.setEnabled(
+            self.model().data(index, Qt.ItemDataRole.UserRole) is not None
+        )
+        menu.addSeparator()
+        
+        menu.addAction(self.tr("Collapse All"), self.collapseAll)
+        menu.addAction(self.tr("Expand All"), self.expandAll)
+        
+        return menu
+    
+    def __createBackgroundContextMenu(self):
+        """
+        Private method to create a context menu for the background.
+        
+        @return created context menu
+        @rtype QMenu
+        """
+        menu = QMenu(self)
+        menu.addAction(self.tr("Collapse All"), self.collapseAll)
+        menu.addAction(self.tr("Expand All"), self.expandAll)
+        
+        return menu
+
+#
+# eflag: noqa = M821, M822

eric ide

mercurial