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