|
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 |