src/eric7/Testing/TestResultsTree.py

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

eric ide

mercurial