|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2002 - 2009 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing the UI to the pyunit package. |
|
8 """ |
|
9 |
|
10 import unittest |
|
11 import sys |
|
12 import traceback |
|
13 import time |
|
14 import re |
|
15 import os |
|
16 |
|
17 from PyQt4.QtCore import * |
|
18 from PyQt4.QtGui import * |
|
19 |
|
20 from E4Gui.E4Application import e4App |
|
21 from E4Gui.E4Completers import E4FileCompleter |
|
22 |
|
23 from Ui_UnittestDialog import Ui_UnittestDialog |
|
24 from Ui_UnittestStacktraceDialog import Ui_UnittestStacktraceDialog |
|
25 |
|
26 from DebugClients.Python.coverage import coverage |
|
27 |
|
28 import UI.PixmapCache |
|
29 |
|
30 import Utilities |
|
31 |
|
32 class UnittestDialog(QWidget, Ui_UnittestDialog): |
|
33 """ |
|
34 Class implementing the UI to the pyunit package. |
|
35 |
|
36 @signal unittestFile(string,int,int) emitted to show the source of a unittest file |
|
37 """ |
|
38 def __init__(self,prog = None,dbs = None,ui = None,parent = None,name = None): |
|
39 """ |
|
40 Constructor |
|
41 |
|
42 @param prog filename of the program to open |
|
43 @param dbs reference to the debug server object. It is an indication |
|
44 whether we were called from within the eric4 IDE |
|
45 @param ui reference to the UI object |
|
46 @param parent parent widget of this dialog (QWidget) |
|
47 @param name name of this dialog (string) |
|
48 """ |
|
49 QWidget.__init__(self,parent) |
|
50 if name: |
|
51 self.setObjectName(name) |
|
52 self.setupUi(self) |
|
53 |
|
54 self.startButton = self.buttonBox.addButton(\ |
|
55 self.trUtf8("Start"), QDialogButtonBox.ActionRole) |
|
56 self.startButton.setToolTip(self.trUtf8("Start the selected testsuite")) |
|
57 self.startButton.setWhatsThis(self.trUtf8(\ |
|
58 """<b>Start Test</b>""" |
|
59 """<p>This button starts the selected testsuite.</p>""")) |
|
60 self.stopButton = self.buttonBox.addButton(\ |
|
61 self.trUtf8("Stop"), QDialogButtonBox.ActionRole) |
|
62 self.stopButton.setToolTip(self.trUtf8("Stop the running unittest")) |
|
63 self.stopButton.setWhatsThis(self.trUtf8(\ |
|
64 """<b>Stop Test</b>""" |
|
65 """<p>This button stops a running unittest.</p>""")) |
|
66 self.stopButton.setEnabled(False) |
|
67 self.startButton.setDefault(True) |
|
68 |
|
69 self.dbs = dbs |
|
70 |
|
71 self.setWindowFlags(\ |
|
72 self.windowFlags() | Qt.WindowFlags(Qt.WindowContextHelpButtonHint)) |
|
73 self.setWindowIcon(UI.PixmapCache.getIcon("eric.png")) |
|
74 self.setWindowTitle(self.trUtf8("Unittest")) |
|
75 if dbs: |
|
76 self.ui = ui |
|
77 else: |
|
78 self.localCheckBox.hide() |
|
79 self.__setProgressColor("green") |
|
80 self.progressLed.setDarkFactor(150) |
|
81 self.progressLed.off() |
|
82 |
|
83 self.testSuiteCompleter = E4FileCompleter(self.testsuiteComboBox) |
|
84 |
|
85 self.fileHistory = [] |
|
86 self.testNameHistory = [] |
|
87 self.running = False |
|
88 self.savedModulelist = None |
|
89 self.savedSysPath = sys.path |
|
90 if prog: |
|
91 self.insertProg(prog) |
|
92 |
|
93 self.rx1 = self.trUtf8("^Failure: ") |
|
94 self.rx2 = self.trUtf8("^Error: ") |
|
95 |
|
96 # now connect the debug server signals if called from the eric4 IDE |
|
97 if self.dbs: |
|
98 self.connect(self.dbs, SIGNAL('utPrepared'), |
|
99 self.__UTPrepared) |
|
100 self.connect(self.dbs, SIGNAL('utFinished'), |
|
101 self.__setStoppedMode) |
|
102 self.connect(self.dbs, SIGNAL('utStartTest'), |
|
103 self.testStarted) |
|
104 self.connect(self.dbs, SIGNAL('utStopTest'), |
|
105 self.testFinished) |
|
106 self.connect(self.dbs, SIGNAL('utTestFailed'), |
|
107 self.testFailed) |
|
108 self.connect(self.dbs, SIGNAL('utTestErrored'), |
|
109 self.testErrored) |
|
110 |
|
111 def __setProgressColor(self, color): |
|
112 """ |
|
113 Private methode to set the color of the progress color label. |
|
114 |
|
115 @param color colour to be shown (string) |
|
116 """ |
|
117 self.progressLed.setColor(QColor(color)) |
|
118 |
|
119 def insertProg(self, prog): |
|
120 """ |
|
121 Public slot to insert the filename prog into the testsuiteComboBox object. |
|
122 |
|
123 @param prog filename to be inserted (string) |
|
124 """ |
|
125 # prepend the selected file to the testsuite combobox |
|
126 if prog is None: |
|
127 prog = "" |
|
128 if prog in self.fileHistory: |
|
129 self.fileHistory.remove(prog) |
|
130 self.fileHistory.insert(0, prog) |
|
131 self.testsuiteComboBox.clear() |
|
132 self.testsuiteComboBox.addItems(self.fileHistory) |
|
133 |
|
134 def insertTestName(self, testName): |
|
135 """ |
|
136 Public slot to insert a test name into the testComboBox object. |
|
137 |
|
138 @param testName name of the test to be inserted (string) |
|
139 """ |
|
140 # prepend the selected file to the testsuite combobox |
|
141 if testName is None: |
|
142 testName = "" |
|
143 if testName in self.testNameHistory: |
|
144 self.testNameHistory.remove(testName) |
|
145 self.testNameHistory.insert(0, testName) |
|
146 self.testComboBox.clear() |
|
147 self.testComboBox.addItems(self.testNameHistory) |
|
148 |
|
149 @pyqtSlot() |
|
150 def on_fileDialogButton_clicked(self): |
|
151 """ |
|
152 Private slot to open a file dialog. |
|
153 """ |
|
154 if self.dbs: |
|
155 pyExtensions = \ |
|
156 ' '.join(["*%s" % ext for ext in self.dbs.getExtensions('Python')]) |
|
157 py3Extensions = \ |
|
158 ' '.join(["*%s" % ext for ext in self.dbs.getExtensions('Python3')]) |
|
159 filter = self.trUtf8("Python Files ({0});;Python3 Files ({1});;All Files (*)")\ |
|
160 .format(pyExtensions, py3Extensions) |
|
161 else: |
|
162 filter = self.trUtf8("Python Files (*.py);;All Files (*)") |
|
163 prog = QFileDialog.getOpenFileName(\ |
|
164 self, |
|
165 "", |
|
166 self.testsuiteComboBox.currentText(), |
|
167 filter) |
|
168 |
|
169 if not prog: |
|
170 return |
|
171 |
|
172 self.insertProg(Utilities.toNativeSeparators(prog)) |
|
173 |
|
174 @pyqtSlot(str) |
|
175 def on_testsuiteComboBox_editTextChanged(self, txt): |
|
176 """ |
|
177 Private slot to handle changes of the test file name. |
|
178 |
|
179 @param txt name of the test file (string) |
|
180 """ |
|
181 if self.dbs: |
|
182 exts = self.dbs.getExtensions("Python3") |
|
183 if txt.endswith(exts): |
|
184 self.coverageCheckBox.setChecked(False) |
|
185 self.coverageCheckBox.setEnabled(False) |
|
186 self.localCheckBox.setChecked(False) |
|
187 self.localCheckBox.setEnabled(False) |
|
188 return |
|
189 |
|
190 self.coverageCheckBox.setEnabled(True) |
|
191 self.localCheckBox.setEnabled(True) |
|
192 |
|
193 def on_buttonBox_clicked(self, button): |
|
194 """ |
|
195 Private slot called by a button of the button box clicked. |
|
196 |
|
197 @param button button that was clicked (QAbstractButton) |
|
198 """ |
|
199 if button == self.startButton: |
|
200 self.on_startButton_clicked() |
|
201 elif button == self.stopButton: |
|
202 self.on_stopButton_clicked() |
|
203 |
|
204 @pyqtSlot() |
|
205 def on_startButton_clicked(self): |
|
206 """ |
|
207 Public slot to start the test. |
|
208 """ |
|
209 if self.running: |
|
210 return |
|
211 |
|
212 prog = self.testsuiteComboBox.currentText() |
|
213 if not prog: |
|
214 QMessageBox.critical(self, |
|
215 self.trUtf8("Unittest"), |
|
216 self.trUtf8("You must enter a test suite file.")) |
|
217 return |
|
218 |
|
219 # prepend the selected file to the testsuite combobox |
|
220 self.insertProg(prog) |
|
221 self.sbLabel.setText(self.trUtf8("Preparing Testsuite")) |
|
222 QApplication.processEvents() |
|
223 |
|
224 testFunctionName = self.testComboBox.currentText() or "suite" |
|
225 |
|
226 # build the module name from the filename without extension |
|
227 self.testName = os.path.splitext(os.path.basename(prog))[0] |
|
228 |
|
229 if self.dbs and not self.localCheckBox.isChecked(): |
|
230 # we are cooperating with the eric4 IDE |
|
231 project = e4App().getObject("Project") |
|
232 if project.isOpen() and project.isProjectSource(prog): |
|
233 mainScript = project.getMainScript(True) |
|
234 else: |
|
235 mainScript = os.path.abspath(prog) |
|
236 self.dbs.remoteUTPrepare(prog, self.testName, testFunctionName, |
|
237 self.coverageCheckBox.isChecked(), mainScript, |
|
238 self.coverageEraseCheckBox.isChecked()) |
|
239 else: |
|
240 # we are running as an application or in local mode |
|
241 sys.path = [os.path.dirname(os.path.abspath(prog))] + self.savedSysPath |
|
242 |
|
243 # clean up list of imported modules to force a reimport upon running the test |
|
244 if self.savedModulelist: |
|
245 for modname in sys.modules: |
|
246 if modname not in self.savedModulelist: |
|
247 # delete it |
|
248 del(sys.modules[modname]) |
|
249 self.savedModulelist = sys.modules.copy() |
|
250 |
|
251 # now try to generate the testsuite |
|
252 try: |
|
253 module = __import__(self.testName) |
|
254 try: |
|
255 test = unittest.defaultTestLoader.loadTestsFromName(\ |
|
256 testFunctionName, module) |
|
257 except AttributeError: |
|
258 test = unittest.defaultTestLoader.loadTestsFromModule(module) |
|
259 except: |
|
260 exc_type, exc_value, exc_tb = sys.exc_info() |
|
261 QMessageBox.critical(self, |
|
262 self.trUtf8("Unittest"), |
|
263 self.trUtf8("<p>Unable to run test <b>{0}</b>.<br>{1}<br>{2}</p>") |
|
264 .format(self.testName, unicode(exc_type), unicode(exc_value))) |
|
265 return |
|
266 |
|
267 # now set up the coverage stuff |
|
268 if self.coverageCheckBox.isChecked(): |
|
269 if self.dbs: |
|
270 # we are cooperating with the eric4 IDE |
|
271 project = e4App().getObject("Project") |
|
272 if project.isOpen() and project.isProjectSource(prog): |
|
273 mainScript = project.getMainScript(True) |
|
274 else: |
|
275 mainScript = os.path.abspath(prog) |
|
276 else: |
|
277 mainScript = os.path.abspath(prog) |
|
278 cover = coverage( |
|
279 data_file = "%s.coverage" % os.path.splitext(mainScript)[0]) |
|
280 cover.use_cache(True) |
|
281 if self.coverageEraseCheckBox.isChecked(): |
|
282 cover.erase() |
|
283 else: |
|
284 cover = None |
|
285 |
|
286 self.testResult = QtTestResult(self) |
|
287 self.totalTests = test.countTestCases() |
|
288 self.__setRunningMode() |
|
289 if cover: |
|
290 cover.start() |
|
291 test.run(self.testResult) |
|
292 if cover: |
|
293 cover.stop() |
|
294 cover.save() |
|
295 self.__setStoppedMode() |
|
296 sys.path = self.savedSysPath |
|
297 |
|
298 def __UTPrepared(self, nrTests, exc_type, exc_value): |
|
299 """ |
|
300 Private slot to handle the utPrepared signal. |
|
301 |
|
302 If the unittest suite was loaded successfully, we ask the |
|
303 client to run the test suite. |
|
304 |
|
305 @param nrTests number of tests contained in the test suite (integer) |
|
306 @param exc_type type of exception occured during preparation (string) |
|
307 @param exc_value value of exception occured during preparation (string) |
|
308 """ |
|
309 if nrTests == 0: |
|
310 QMessageBox.critical(self, |
|
311 self.trUtf8("Unittest"), |
|
312 self.trUtf8("<p>Unable to run test <b>{0}</b>.<br>{1}<br>{2}</p>") |
|
313 .format(self.testName, exc_type, exc_value)) |
|
314 return |
|
315 |
|
316 self.totalTests = nrTests |
|
317 self.__setRunningMode() |
|
318 self.dbs.remoteUTRun() |
|
319 |
|
320 @pyqtSlot() |
|
321 def on_stopButton_clicked(self): |
|
322 """ |
|
323 Private slot to stop the test. |
|
324 """ |
|
325 if self.dbs and not self.localCheckBox.isChecked(): |
|
326 self.dbs.remoteUTStop() |
|
327 elif self.testResult: |
|
328 self.testResult.stop() |
|
329 |
|
330 def on_errorsListWidget_currentTextChanged(self, text): |
|
331 """ |
|
332 Private slot to handle the highlighted signal. |
|
333 |
|
334 @param txt current text (string) |
|
335 """ |
|
336 if text: |
|
337 text = re.sub(self.rx1, "", text) |
|
338 text = re.sub(self.rx2, "", text) |
|
339 itm = self.testsListWidget.findItems(text, Qt.MatchFlags(Qt.MatchExactly))[0] |
|
340 self.testsListWidget.setCurrentItem(itm) |
|
341 self.testsListWidget.scrollToItem(itm) |
|
342 |
|
343 def __setRunningMode(self): |
|
344 """ |
|
345 Private method to set the GUI in running mode. |
|
346 """ |
|
347 self.running = True |
|
348 |
|
349 # reset counters and error infos |
|
350 self.runCount = 0 |
|
351 self.failCount = 0 |
|
352 self.errorCount = 0 |
|
353 self.remainingCount = self.totalTests |
|
354 self.errorInfo = [] |
|
355 |
|
356 # reset the GUI |
|
357 self.progressCounterRunCount.setText(str(self.runCount)) |
|
358 self.progressCounterFailureCount.setText(str(self.failCount)) |
|
359 self.progressCounterErrorCount.setText(str(self.errorCount)) |
|
360 self.progressCounterRemCount.setText(str(self.remainingCount)) |
|
361 self.errorsListWidget.clear() |
|
362 self.testsListWidget.clear() |
|
363 self.progressProgressBar.setRange(0, self.totalTests) |
|
364 self.__setProgressColor("green") |
|
365 self.progressProgressBar.reset() |
|
366 self.stopButton.setEnabled(True) |
|
367 self.startButton.setEnabled(False) |
|
368 self.stopButton.setDefault(True) |
|
369 self.sbLabel.setText(self.trUtf8("Running")) |
|
370 self.progressLed.on() |
|
371 QApplication.processEvents() |
|
372 |
|
373 self.startTime = time.time() |
|
374 |
|
375 def __setStoppedMode(self): |
|
376 """ |
|
377 Private method to set the GUI in stopped mode. |
|
378 """ |
|
379 self.stopTime = time.time() |
|
380 self.timeTaken = float(self.stopTime - self.startTime) |
|
381 self.running = False |
|
382 |
|
383 self.startButton.setEnabled(True) |
|
384 self.stopButton.setEnabled(False) |
|
385 self.startButton.setDefault(True) |
|
386 if self.runCount == 1: |
|
387 self.sbLabel.setText(self.trUtf8("Ran {0} test in {1:.3f}s") |
|
388 .format(self.runCount, self.timeTaken)) |
|
389 else: |
|
390 self.sbLabel.setText(self.trUtf8("Ran {0} tests in {1:.3f}s") |
|
391 .format(self.runCount, self.timeTaken)) |
|
392 self.progressLed.off() |
|
393 |
|
394 def testFailed(self, test, exc): |
|
395 """ |
|
396 Public method called if a test fails. |
|
397 |
|
398 @param test name of the failed test (string) |
|
399 @param exc string representation of the exception (list of strings) |
|
400 """ |
|
401 self.failCount += 1 |
|
402 self.progressCounterFailureCount.setText(str(self.failCount)) |
|
403 self.errorsListWidget.insertItem(0, self.trUtf8("Failure: {0}").format(test)) |
|
404 self.errorInfo.insert(0, (test, exc)) |
|
405 |
|
406 def testErrored(self, test, exc): |
|
407 """ |
|
408 Public method called if a test errors. |
|
409 |
|
410 @param test name of the failed test (string) |
|
411 @param exc string representation of the exception (list of strings) |
|
412 """ |
|
413 self.errorCount += 1 |
|
414 self.progressCounterErrorCount.setText(str(self.errorCount)) |
|
415 self.errorsListWidget.insertItem(0, self.trUtf8("Error: {0}").format(test)) |
|
416 self.errorInfo.insert(0, (test, exc)) |
|
417 |
|
418 def testStarted(self, test, doc): |
|
419 """ |
|
420 Public method called if a test is about to be run. |
|
421 |
|
422 @param test name of the started test (string) |
|
423 @param doc documentation of the started test (string) |
|
424 """ |
|
425 if doc: |
|
426 self.testsListWidget.insertItem(0, " %s" % doc) |
|
427 self.testsListWidget.insertItem(0, test) |
|
428 if self.dbs is None or self.localCheckBox.isChecked(): |
|
429 QApplication.processEvents() |
|
430 |
|
431 def testFinished(self): |
|
432 """ |
|
433 Public method called if a test has finished. |
|
434 |
|
435 <b>Note</b>: It is also called if it has already failed or errored. |
|
436 """ |
|
437 # update the counters |
|
438 self.remainingCount -= 1 |
|
439 self.runCount += 1 |
|
440 self.progressCounterRunCount.setText(str(self.runCount)) |
|
441 self.progressCounterRemCount.setText(str(self.remainingCount)) |
|
442 |
|
443 # update the progressbar |
|
444 if self.errorCount: |
|
445 self.__setProgressColor("red") |
|
446 elif self.failCount: |
|
447 self.__setProgressColor("orange") |
|
448 self.progressProgressBar.setValue(self.runCount) |
|
449 |
|
450 def on_errorsListWidget_itemDoubleClicked(self, lbitem): |
|
451 """ |
|
452 Private slot called by doubleclicking an errorlist entry. |
|
453 |
|
454 It will popup a dialog showing the stacktrace. |
|
455 If called from eric, an additional button is displayed |
|
456 to show the python source in an eric source viewer (in |
|
457 erics main window. |
|
458 |
|
459 @param lbitem the listbox item that was double clicked |
|
460 """ |
|
461 self.errListIndex = self.errorsListWidget.row(lbitem) |
|
462 text = lbitem.text() |
|
463 |
|
464 # get the error info |
|
465 test, tracebackLines = self.errorInfo[self.errListIndex] |
|
466 tracebackText = "".join(tracebackLines) |
|
467 |
|
468 # now build the dialog |
|
469 self.dlg = QDialog() |
|
470 ui = Ui_UnittestStacktraceDialog() |
|
471 ui.setupUi(self.dlg) |
|
472 |
|
473 ui.showButton = ui.buttonBox.addButton(\ |
|
474 self.trUtf8("Show Source"), QDialogButtonBox.ActionRole) |
|
475 ui.buttonBox.button(QDialogButtonBox.Close).setDefault(True) |
|
476 |
|
477 self.dlg.setWindowTitle(text) |
|
478 ui.testLabel.setText(test) |
|
479 ui.traceback.setPlainText(tracebackText) |
|
480 |
|
481 # one more button if called from eric |
|
482 if self.dbs: |
|
483 self.dlg.connect(ui.showButton, SIGNAL("clicked()"), |
|
484 self.__showSource) |
|
485 else: |
|
486 ui.showButton.hide() |
|
487 |
|
488 # and now fire it up |
|
489 self.dlg.show() |
|
490 self.dlg.exec_() |
|
491 |
|
492 def __showSource(self): |
|
493 """ |
|
494 Private slot to show the source of a traceback in an eric4 editor. |
|
495 """ |
|
496 if not self.dbs: |
|
497 return |
|
498 |
|
499 # get the error info |
|
500 test, tracebackLines = self.errorInfo[self.errListIndex] |
|
501 # find the last entry matching the pattern |
|
502 for index in range(len(tracebackLines) - 1, -1, -1): |
|
503 fmatch = re.search(r'File "(.*?)", line (\d*?),.*', tracebackLines[index]) |
|
504 if fmatch: |
|
505 break |
|
506 if fmatch: |
|
507 fn, ln = fmatch.group(1, 2) |
|
508 self.emit(SIGNAL('unittestFile'), fn, int(ln), 1) |
|
509 |
|
510 class QtTestResult(unittest.TestResult): |
|
511 """ |
|
512 A TestResult derivative to work with a graphical GUI. |
|
513 |
|
514 For more details see pyunit.py of the standard python distribution. |
|
515 """ |
|
516 def __init__(self, parent): |
|
517 """ |
|
518 Constructor |
|
519 |
|
520 @param parent The parent widget. |
|
521 """ |
|
522 unittest.TestResult.__init__(self) |
|
523 self.parent = parent |
|
524 |
|
525 def addFailure(self, test, err): |
|
526 """ |
|
527 Method called if a test failed. |
|
528 |
|
529 @param test Reference to the test object |
|
530 @param err The error traceback |
|
531 """ |
|
532 unittest.TestResult.addFailure(self, test, err) |
|
533 tracebackLines = apply(traceback.format_exception, err + (10,)) |
|
534 self.parent.testFailed(unicode(test), tracebackLines) |
|
535 |
|
536 def addError(self, test, err): |
|
537 """ |
|
538 Method called if a test errored. |
|
539 |
|
540 @param test Reference to the test object |
|
541 @param err The error traceback |
|
542 """ |
|
543 unittest.TestResult.addError(self, test, err) |
|
544 tracebackLines = apply(traceback.format_exception, err + (10,)) |
|
545 self.parent.testErrored(unicode(test), tracebackLines) |
|
546 |
|
547 def startTest(self, test): |
|
548 """ |
|
549 Method called at the start of a test. |
|
550 |
|
551 @param test Reference to the test object |
|
552 """ |
|
553 unittest.TestResult.startTest(self, test) |
|
554 self.parent.testStarted(unicode(test), test.shortDescription()) |
|
555 |
|
556 def stopTest(self, test): |
|
557 """ |
|
558 Method called at the end of a test. |
|
559 |
|
560 @param test Reference to the test object |
|
561 """ |
|
562 unittest.TestResult.stopTest(self, test) |
|
563 self.parent.testFinished() |
|
564 |
|
565 class UnittestWindow(QMainWindow): |
|
566 """ |
|
567 Main window class for the standalone dialog. |
|
568 """ |
|
569 def __init__(self, prog = None, parent = None): |
|
570 """ |
|
571 Constructor |
|
572 |
|
573 @param prog filename of the program to open |
|
574 @param parent reference to the parent widget (QWidget) |
|
575 """ |
|
576 QMainWindow.__init__(self, parent) |
|
577 self.cw = UnittestDialog(prog = prog, parent = self) |
|
578 self.cw.installEventFilter(self) |
|
579 size = self.cw.size() |
|
580 self.setCentralWidget(self.cw) |
|
581 self.resize(size) |
|
582 |
|
583 def eventFilter(self, obj, event): |
|
584 """ |
|
585 Public method to filter events. |
|
586 |
|
587 @param obj reference to the object the event is meant for (QObject) |
|
588 @param event reference to the event object (QEvent) |
|
589 @return flag indicating, whether the event was handled (boolean) |
|
590 """ |
|
591 if event.type() == QEvent.Close: |
|
592 QApplication.exit() |
|
593 return True |
|
594 |
|
595 return False |