eric7/Testing/TestResultsTree.py

branch
unittest
changeset 9066
a219ade50f7c
parent 9065
39405e6eba20
child 9160
1675c039a568
equal deleted inserted replaced
9065:39405e6eba20 9066:a219ade50f7c
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing a tree view and associated model to show the test result
8 data.
9 """
10
11 import contextlib
12 import copy
13 import locale
14
15 from collections import Counter
16 from operator import attrgetter
17
18 from PyQt6.QtCore import (
19 pyqtSignal, pyqtSlot, Qt, QAbstractItemModel, QCoreApplication,
20 QModelIndex, QPoint
21 )
22 from PyQt6.QtGui import QBrush, QColor
23 from PyQt6.QtWidgets import QMenu, QTreeView
24
25 from EricWidgets.EricApplication import ericApp
26
27 import Preferences
28
29 from .Interfaces.TestExecutorBase import TestResultCategory
30
31 TopLevelId = 2 ** 32 - 1
32
33
34 class TestResultsModel(QAbstractItemModel):
35 """
36 Class implementing the item model containing the test data.
37
38 @signal summary(str) emitted whenever the model data changes. The element
39 is a summary of the test results of the model.
40 """
41 summary = pyqtSignal(str)
42
43 Headers = [
44 QCoreApplication.translate("TestResultsModel", "Status"),
45 QCoreApplication.translate("TestResultsModel", "Name"),
46 QCoreApplication.translate("TestResultsModel", "Message"),
47 QCoreApplication.translate("TestResultsModel", "Duration [ms]"),
48 ]
49
50 StatusColumn = 0
51 NameColumn = 1
52 MessageColumn = 2
53 DurationColumn = 3
54
55 def __init__(self, parent=None):
56 """
57 Constructor
58
59 @param parent reference to the parent object (defaults to None)
60 @type QObject (optional)
61 """
62 super().__init__(parent)
63
64 if ericApp().usesDarkPalette():
65 self.__backgroundColors = {
66 TestResultCategory.RUNNING: None,
67 TestResultCategory.FAIL: QBrush(QColor("#880000")),
68 TestResultCategory.OK: QBrush(QColor("#005500")),
69 TestResultCategory.SKIP: QBrush(QColor("#3f3f3f")),
70 TestResultCategory.PENDING: QBrush(QColor("#004768")),
71 }
72 else:
73 self.__backgroundColors = {
74 TestResultCategory.RUNNING: None,
75 TestResultCategory.FAIL: QBrush(QColor("#ff8080")),
76 TestResultCategory.OK: QBrush(QColor("#c1ffba")),
77 TestResultCategory.SKIP: QBrush(QColor("#c5c5c5")),
78 TestResultCategory.PENDING: QBrush(QColor("#6fbaff")),
79 }
80
81 self.__testResults = []
82
83 def index(self, row, column, parent=QModelIndex()):
84 """
85 Public method to generate an index for the given row and column to
86 identify the item.
87
88 @param row row for the index
89 @type int
90 @param column column for the index
91 @type int
92 @param parent index of the parent item (defaults to QModelIndex())
93 @type QModelIndex (optional)
94 @return index for the item
95 @rtype QModelIndex
96 """
97 if not self.hasIndex(row, column, parent): # check bounds etc.
98 return QModelIndex()
99
100 if not parent.isValid():
101 # top level item
102 return self.createIndex(row, column, TopLevelId)
103 else:
104 testResultIndex = parent.row()
105 return self.createIndex(row, column, testResultIndex)
106
107 def data(self, index, role):
108 """
109 Public method to get the data for the various columns and roles.
110
111 @param index index of the data to be returned
112 @type QModelIndex
113 @param role role designating the data to return
114 @type Qt.ItemDataRole
115 @return requested data item
116 @rtype Any
117 """
118 if not index.isValid():
119 return None
120
121 row = index.row()
122 column = index.column()
123 idx = index.internalId()
124
125 if role == Qt.ItemDataRole.DisplayRole:
126 if idx != TopLevelId:
127 if bool(self.__testResults[idx].extra):
128 return self.__testResults[idx].extra[index.row()]
129 else:
130 return None
131 elif column == TestResultsModel.StatusColumn:
132 return self.__testResults[row].status
133 elif column == TestResultsModel.NameColumn:
134 return self.__testResults[row].name
135 elif column == TestResultsModel.MessageColumn:
136 return self.__testResults[row].message
137 elif column == TestResultsModel.DurationColumn:
138 duration = self.__testResults[row].duration
139 return (
140 ""
141 if duration is None else
142 locale.format_string("%.2f", duration, grouping=True)
143 )
144 elif role == Qt.ItemDataRole.ToolTipRole:
145 if idx == TopLevelId and column == TestResultsModel.NameColumn:
146 return self.__testResults[row].name
147 elif role == Qt.ItemDataRole.FontRole:
148 if idx != TopLevelId:
149 return Preferences.getEditorOtherFonts("MonospacedFont")
150 elif role == Qt.ItemDataRole.BackgroundRole:
151 if idx == TopLevelId:
152 testResult = self.__testResults[row]
153 with contextlib.suppress(KeyError):
154 return self.__backgroundColors[testResult.category]
155 elif role == Qt.ItemDataRole.TextAlignmentRole:
156 if idx == TopLevelId and column == TestResultsModel.DurationColumn:
157 return Qt.AlignmentFlag.AlignRight
158 elif role == Qt.ItemDataRole.UserRole: # __IGNORE_WARNING_Y102__
159 if idx == TopLevelId:
160 testresult = self.__testResults[row]
161 return (testresult.filename, testresult.lineno)
162
163 return None
164
165 def headerData(self, section, orientation,
166 role=Qt.ItemDataRole.DisplayRole):
167 """
168 Public method to get the header string for the various sections.
169
170 @param section section number
171 @type int
172 @param orientation orientation of the header
173 @type Qt.Orientation
174 @param role data role (defaults to Qt.ItemDataRole.DisplayRole)
175 @type Qt.ItemDataRole (optional)
176 @return header string of the section
177 @rtype str
178 """
179 if (
180 orientation == Qt.Orientation.Horizontal and
181 role == Qt.ItemDataRole.DisplayRole
182 ):
183 return TestResultsModel.Headers[section]
184 else:
185 return None
186
187 def parent(self, index):
188 """
189 Public method to get the parent of the item pointed to by index.
190
191 @param index index of the item
192 @type QModelIndex
193 @return index of the parent item
194 @rtype QModelIndex
195 """
196 if not index.isValid():
197 return QModelIndex()
198
199 idx = index.internalId()
200 if idx == TopLevelId:
201 return QModelIndex()
202 else:
203 return self.index(idx, 0)
204
205 def rowCount(self, parent=QModelIndex()):
206 """
207 Public method to get the number of row for a given parent index.
208
209 @param parent index of the parent item (defaults to QModelIndex())
210 @type QModelIndex (optional)
211 @return number of rows
212 @rtype int
213 """
214 if not parent.isValid():
215 return len(self.__testResults)
216
217 if (
218 parent.internalId() == TopLevelId and
219 parent.column() == 0 and
220 self.__testResults[parent.row()].extra is not None
221 ):
222 return len(self.__testResults[parent.row()].extra)
223
224 return 0
225
226 def columnCount(self, parent=QModelIndex()):
227 """
228 Public method to get the number of columns.
229
230 @param parent index of the parent item (defaults to QModelIndex())
231 @type QModelIndex (optional)
232 @return number of columns
233 @rtype int
234 """
235 if not parent.isValid():
236 return len(TestResultsModel.Headers)
237 else:
238 return 1
239
240 def clear(self):
241 """
242 Public method to clear the model data.
243 """
244 self.beginResetModel()
245 self.__testResults.clear()
246 self.endResetModel()
247
248 self.summary.emit("")
249
250 def sort(self, column, order):
251 """
252 Public method to sort the model data by column in order.
253
254 @param column sort column number
255 @type int
256 @param order sort order
257 @type Qt.SortOrder
258 """ # __IGNORE_WARNING_D234r__
259 def durationKey(result):
260 """
261 Function to generate a key for duration sorting
262
263 @param result result object
264 @type TestResult
265 @return sort key
266 @rtype float
267 """
268 return result.duration or -1.0
269
270 self.beginResetModel()
271 reverse = order == Qt.SortOrder.DescendingOrder
272 if column == TestResultsModel.StatusColumn:
273 self.__testResults.sort(key=attrgetter('category', 'status'),
274 reverse=reverse)
275 elif column == TestResultsModel.NameColumn:
276 self.__testResults.sort(key=attrgetter('name'), reverse=reverse)
277 elif column == TestResultsModel.MessageColumn:
278 self.__testResults.sort(key=attrgetter('message'), reverse=reverse)
279 elif column == TestResultsModel.DurationColumn:
280 self.__testResults.sort(key=durationKey, reverse=reverse)
281 self.endResetModel()
282
283 def getTestResults(self):
284 """
285 Public method to get the list of test results managed by the model.
286
287 @return list of test results managed by the model
288 @rtype list of TestResult
289 """
290 return copy.deepcopy(self.__testResults)
291
292 def setTestResults(self, testResults):
293 """
294 Public method to set the list of test results of the model.
295
296 @param testResults test results to be managed by the model
297 @type list of TestResult
298 """
299 self.beginResetModel()
300 self.__testResults = copy.deepcopy(testResults)
301 self.endResetModel()
302
303 self.summary.emit(self.__summary())
304
305 def addTestResults(self, testResults):
306 """
307 Public method to add test results to the ones already managed by the
308 model.
309
310 @param testResults test results to be added to the model
311 @type list of TestResult
312 """
313 firstRow = len(self.__testResults)
314 lastRow = firstRow + len(testResults) - 1
315 self.beginInsertRows(QModelIndex(), firstRow, lastRow)
316 self.__testResults.extend(testResults)
317 self.endInsertRows()
318
319 self.summary.emit(self.__summary())
320
321 def updateTestResults(self, testResults):
322 """
323 Public method to update the data of managed test result items.
324
325 @param testResults test results to be updated
326 @type list of TestResult
327 """
328 minIndex = None
329 maxIndex = None
330
331 testResultsToBeAdded = []
332
333 for testResult in testResults:
334 for (index, currentResult) in enumerate(self.__testResults):
335 if currentResult.id == testResult.id:
336 self.__testResults[index] = testResult
337 if minIndex is None:
338 minIndex = index
339 maxIndex = index
340 else:
341 minIndex = min(minIndex, index)
342 maxIndex = max(maxIndex, index)
343
344 break
345 else:
346 # Test result with given id was not found.
347 # Just add it to the list (could be a sub test)
348 testResultsToBeAdded.append(testResult)
349
350 if minIndex is not None:
351 self.dataChanged.emit(
352 self.index(minIndex, 0),
353 self.index(maxIndex, len(TestResultsModel.Headers) - 1)
354 )
355
356 self.summary.emit(self.__summary())
357
358 if testResultsToBeAdded:
359 self.addTestResults(testResultsToBeAdded)
360
361 def getFailedTests(self):
362 """
363 Public method to extract the test ids of all failed tests.
364
365 @return test ids of all failed tests
366 @rtype list of str
367 """
368 failedIds = [
369 res.id for res in self.__testResults if (
370 res.category == TestResultCategory.FAIL and
371 not res.subtestResult
372 )
373 ]
374 return failedIds
375
376 def __summary(self):
377 """
378 Private method to generate a test results summary text.
379
380 @return test results summary text
381 @rtype str
382 """
383 if len(self.__testResults) == 0:
384 return self.tr("No results to show")
385
386 counts = Counter(res.category for res in self.__testResults)
387 if all(
388 counts[category] == 0
389 for category in (TestResultCategory.FAIL, TestResultCategory.OK,
390 TestResultCategory.SKIP)
391 ):
392 return self.tr("Collected %n test(s)", "", len(self.__testResults))
393
394 return self.tr(
395 "%n test(s)/subtest(s) total, {0} failed, {1} passed,"
396 " {2} skipped, {3} pending",
397 "", len(self.__testResults)
398 ).format(
399 counts[TestResultCategory.FAIL],
400 counts[TestResultCategory.OK],
401 counts[TestResultCategory.SKIP],
402 counts[TestResultCategory.PENDING]
403 )
404
405
406 class TestResultsTreeView(QTreeView):
407 """
408 Class implementing a tree view to show the test result data.
409
410 @signal goto(str, int) emitted to go to the position given by file name
411 and line number
412 """
413 goto = pyqtSignal(str, int)
414
415 def __init__(self, parent=None):
416 """
417 Constructor
418
419 @param parent reference to the parent widget (defaults to None)
420 @type QWidget (optional)
421 """
422 super().__init__(parent)
423
424 self.setItemsExpandable(True)
425 self.setExpandsOnDoubleClick(False)
426 self.setSortingEnabled(True)
427
428 self.header().setDefaultAlignment(Qt.AlignmentFlag.AlignCenter)
429 self.header().setSortIndicatorShown(False)
430
431 self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
432
433 # connect signals and slots
434 self.doubleClicked.connect(self.__gotoTestDefinition)
435 self.customContextMenuRequested.connect(self.__showContextMenu)
436
437 self.header().sortIndicatorChanged.connect(self.sortByColumn)
438 self.header().sortIndicatorChanged.connect(
439 lambda column, order: self.header().setSortIndicatorShown(True))
440
441 def reset(self):
442 """
443 Public method to reset the internal state of the view.
444 """
445 super().reset()
446
447 self.resizeColumns()
448 self.spanFirstColumn(0, self.model().rowCount() - 1)
449
450 def rowsInserted(self, parent, startRow, endRow):
451 """
452 Public method called when rows are inserted.
453
454 @param parent model index of the parent item
455 @type QModelIndex
456 @param startRow first row been inserted
457 @type int
458 @param endRow last row been inserted
459 @type int
460 """
461 super().rowsInserted(parent, startRow, endRow)
462
463 self.resizeColumns()
464 self.spanFirstColumn(startRow, endRow)
465
466 def dataChanged(self, topLeft, bottomRight, roles=[]):
467 """
468 Public method called when the model data has changed.
469
470 @param topLeft index of the top left element
471 @type QModelIndex
472 @param bottomRight index of the bottom right element
473 @type QModelIndex
474 @param roles list of roles changed (defaults to [])
475 @type list of Qt.ItemDataRole (optional)
476 """
477 super().dataChanged(topLeft, bottomRight, roles)
478
479 self.resizeColumns()
480 while topLeft.parent().isValid():
481 topLeft = topLeft.parent()
482 while bottomRight.parent().isValid():
483 bottomRight = bottomRight.parent()
484 self.spanFirstColumn(topLeft.row(), bottomRight.row())
485
486 def resizeColumns(self):
487 """
488 Public method to resize the columns to their contents.
489 """
490 for column in range(self.model().columnCount()):
491 self.resizeColumnToContents(column)
492
493 def spanFirstColumn(self, startRow, endRow):
494 """
495 Public method to make the first column span the row for second level
496 items.
497
498 These items contain the test results.
499
500 @param startRow index of the first row to span
501 @type QModelIndex
502 @param endRow index of the last row (including) to span
503 @type QModelIndex
504 """
505 model = self.model()
506 for row in range(startRow, endRow + 1):
507 index = model.index(row, 0)
508 for i in range(model.rowCount(index)):
509 self.setFirstColumnSpanned(i, index, True)
510
511 def __canonicalIndex(self, index):
512 """
513 Private method to create the canonical index for a given index.
514
515 The canonical index is the index of the first column of the test
516 result entry (i.e. the top-level item). If the index is invalid,
517 None is returned.
518
519 @param index index to determine the canonical index for
520 @type QModelIndex
521 @return index of the firt column of the associated top-level item index
522 @rtype QModelIndex
523 """
524 if not index.isValid():
525 return None
526
527 while index.parent().isValid(): # find the top-level node
528 index = index.parent()
529 index = index.sibling(index.row(), 0) # go to first column
530 return index
531
532 @pyqtSlot(QModelIndex)
533 def __gotoTestDefinition(self, index):
534 """
535 Private slot to show the test definition.
536
537 @param index index for the double-clicked item
538 @type QModelIndex
539 """
540 cindex = self.__canonicalIndex(index)
541 filename, lineno = self.model().data(cindex, Qt.ItemDataRole.UserRole)
542 if filename is not None:
543 if lineno is None:
544 lineno = 1
545 self.goto.emit(filename, lineno)
546
547 @pyqtSlot(QPoint)
548 def __showContextMenu(self, pos):
549 """
550 Private slot to show the context menu.
551
552 @param pos relative position for the context menu
553 @type QPoint
554 """
555 index = self.indexAt(pos)
556 cindex = self.__canonicalIndex(index)
557
558 contextMenu = (
559 self.__createContextMenu(cindex)
560 if cindex else
561 self.__createBackgroundContextMenu()
562 )
563 contextMenu.exec(self.mapToGlobal(pos))
564
565 def __createContextMenu(self, index):
566 """
567 Private method to create a context menu for the item pointed to by the
568 given index.
569
570 @param index index of the item
571 @type QModelIndex
572 @return created context menu
573 @rtype QMenu
574 """
575 menu = QMenu(self)
576 if self.isExpanded(index):
577 menu.addAction(self.tr("Collapse"),
578 lambda: self.collapse(index))
579 else:
580 act = menu.addAction(self.tr("Expand"),
581 lambda: self.expand(index))
582 act.setEnabled(self.model().hasChildren(index))
583 menu.addSeparator()
584
585 act = menu.addAction(self.tr("Show Source"),
586 lambda: self.__gotoTestDefinition(index))
587 act.setEnabled(
588 self.model().data(index, Qt.ItemDataRole.UserRole) is not None
589 )
590 menu.addSeparator()
591
592 menu.addAction(self.tr("Collapse All"), self.collapseAll)
593 menu.addAction(self.tr("Expand All"), self.expandAll)
594
595 return menu
596
597 def __createBackgroundContextMenu(self):
598 """
599 Private method to create a context menu for the background.
600
601 @return created context menu
602 @rtype QMenu
603 """
604 menu = QMenu(self)
605 menu.addAction(self.tr("Collapse All"), self.collapseAll)
606 menu.addAction(self.tr("Expand All"), self.expandAll)
607
608 return menu
609
610 #
611 # eflag: noqa = M821, M822

eric ide

mercurial