File: modal_dialog_tester.py

package info (click to toggle)
python-pyface 8.0.0-5
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 13,944 kB
  • sloc: python: 54,107; makefile: 82
file content (342 lines) | stat: -rw-r--r-- 11,907 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX
# All rights reserved.
#
# This software is provided without warranty under the terms of the BSD
# license included in LICENSE.txt and may be redistributed only under
# the conditions described in the aforementioned license. The license
# is also available online at http://www.enthought.com/licenses/BSD.txt
#
# Thanks for using Enthought open source!
""" A class to facilitate testing components that use TraitsUI or Qt Dialogs.

"""
import contextlib
import sys
import traceback

from pyface.api import GUI, OK, CANCEL, YES, NO
from pyface.qt import QtCore, QtGui
from traits.api import Undefined

from .event_loop_helper import EventLoopHelper
from .testing import find_qt_widget


BUTTON_TEXT = {OK: "OK", CANCEL: "Cancel", YES: "Yes", NO: "No"}


class ModalDialogTester(object):
    """ Test helper for code that open a traits ui or QDialog window.

    Notes
    -----
    ::

        # Common usage calling a `function` that will open a dialog and then
        # accept the dialog info.
        tester = ModalDialogTester(function)
        tester.open_and_run(when_opened=lambda x: x.close(accept=True))
        self.assertEqual(tester.result, <expected>)

        # Even if the dialog was not opened upon calling `function`,
        # `result` is assigned and the test may not fail.
        # To test if the dialog was once opened:
        self.assertTrue(tester.dialog_was_opened)

    .. note::

       - Proper operation assumes that at all times the dialog is a modal
         window.
       - Errors and failures during the when_opened call do not register with
         the unittest testcases because they take place on a deferred call in
         the event loop. It is advised that the `capture_error` context
         manager is used from the GuiTestAssistant when necessary.

    """

    def __init__(self, function):
        #: The command to call that will cause a dialog to open.
        self.function = function
        self._assigned = False
        self._result = Undefined
        self._qt_app = QtGui.QApplication.instance()
        self._gui = GUI()
        self._event_loop_error = []
        self._helper = EventLoopHelper(qt_app=self._qt_app, gui=self._gui)
        self._dialog_widget = None
        self.dialog_was_opened = False

    @property
    def result(self):
        """ The return value of the provided function.

        """
        return self._result

    @result.setter
    def result(self, value):
        """ Setter methods for the result attribute.

        """
        self._assigned = True
        self._result = value

    def open_and_run(self, when_opened, *args, **kwargs):
        """ Execute the function to open the dialog and run ``when_opened``.

        Parameters
        ----------
        when_opened : Callable
            A callable to be called when the dialog has been created and
            opened. The callable with be called with the tester instance
            as argument.

        *args, **kwargs :
            Additional arguments to be passed to the `function`
            attribute of the tester.

        Raises
        ------
        AssertionError if an assertion error was captured during the
        deferred calls that open and close the dialog.
        RuntimeError if a result value has not been assigned within 15
        seconds after calling `self.function`
        Any other exception that was captured during the deferred calls
        that open and close the dialog.

        .. note:: This method is synchronous

        """
        condition_timer = QtCore.QTimer()

        def handler():
            """ Run the when_opened as soon as the dialog has opened. """
            if self.dialog_opened():
                self._gui.invoke_later(when_opened, self)
                self.dialog_was_opened = True
            else:
                condition_timer.start()

        # Setup and start the timer to fire the handler every 100 msec.
        condition_timer.setInterval(100)
        condition_timer.setSingleShot(True)
        condition_timer.timeout.connect(handler)
        condition_timer.start()

        self._assigned = False
        try:
            # open the dialog on a deferred call.
            self._gui.invoke_later(self.open, *args, **kwargs)
            # wait in the event loop until timeout or a return value assigned.
            self._helper.event_loop_until_condition(
                condition=self.value_assigned, timeout=15
            )
        finally:
            condition_timer.stop()
            condition_timer.timeout.disconnect(handler)
            self._helper.event_loop()
            self.assert_no_errors_collected()

    def open_and_wait(self, when_opened, *args, **kwargs):
        """ Execute the function to open the dialog and wait to be closed.

        Parameters
        ----------
        when_opened : Callable
            A callable to be called when the dialog has been created and
            opened. The callable with be called with the tester instance
            as argument.

        *args, **kwargs :
            Additional arguments to be passed to the `function`
            attribute of the tester.

        Raises
        ------
        AssertionError if an assertion error was captured during the
        deferred calls that open and close the dialog.
        RuntimeError if the dialog has not been closed within 15 seconds after
        calling `self.function`.
        Any other exception that was captured during the deferred calls
        that open and close the dialog.

        .. note:: This method is synchronous

        """
        condition_timer = QtCore.QTimer()

        def handler():
            """ Run the when_opened as soon as the dialog has opened. """
            if self.dialog_opened():
                self._dialog_widget = self.get_dialog_widget()
                self._gui.invoke_later(when_opened, self)
                self.dialog_was_opened = True
            else:
                condition_timer.start()

        def condition():
            if self._dialog_widget is None:
                return False
            else:
                value = self.get_dialog_widget() != self._dialog_widget
                if value:
                    # process any pending events so that we have a clean
                    # event loop before we exit.
                    self._helper.event_loop()
                return value

        # Setup and start the timer to signal the handler every 100 msec.
        condition_timer.setInterval(100)
        condition_timer.setSingleShot(True)
        condition_timer.timeout.connect(handler)
        condition_timer.start()

        self._assigned = False
        try:
            # open the dialog on a deferred call.
            self._gui.invoke_later(self.open, *args, **kwargs)
            # wait in the event loop until timeout or a return value assigned.
            self._helper.event_loop_until_condition(
                condition=condition, timeout=15
            )
        finally:
            condition_timer.stop()
            condition_timer.timeout.disconnect(handler)
            self._dialog_widget = None
            self._helper.event_loop()
            self.assert_no_errors_collected()

    def open(self, *args, **kwargs):
        """ Execute the function that will cause a dialog to be opened.

        Parameters
        ----------
        *args, **kwargs :
            Arguments to be passed to the `function` attribute of the
            tester.

        .. note:: This method is synchronous

        """
        with self.capture_error():
            self.result = self.function(*args, **kwargs)

    def close(self, accept=False):
        """ Close the dialog by accepting or rejecting.

        """
        with self.capture_error():
            widget = self.get_dialog_widget()
            if accept:
                self._gui.invoke_later(widget.accept)
            else:
                self._gui.invoke_later(widget.reject)

    @contextlib.contextmanager
    def capture_error(self):
        """ Capture exceptions, to be used while running inside an event loop.

        When errors and failures take place through an invoke later command
        they might not be caught by the unittest machinery. This context
        manager when used inside a deferred call, will capture the fact that
        an error has occurred and the user can later use the `check for errors`
        command which will raise an error or failure if necessary.

        """
        try:
            yield
        except Exception:
            self._event_loop_error.append(
                (sys.exc_info()[0], traceback.format_exc())
            )

    def assert_no_errors_collected(self):
        """ Assert that the tester has not collected any errors.

        """
        if len(self._event_loop_error) > 0:
            msg = "The following error(s) were detected:\n\n{0}"
            tracebacks = []
            for type_, message in self._event_loop_error:
                if isinstance(type_, AssertionError):
                    msg = "The following failure(s) were detected:\n\n{0}"
                tracebacks.append(message)

            raise type_(msg.format("\n\n".join(tracebacks)))

    def click_widget(self, text, type_=QtGui.QPushButton):
        """ Execute click on the widget of `type_` with `text`.

        This strips '&' chars from the string, since usage varies from platform
        to platform.
        """
        control = self.get_dialog_widget()

        def test(widget):
            # XXX asking for widget.text() causes occasional segfaults on Linux
            # and pyqt (both 4 and 5).  Not sure why this is happening.
            # See issue #282
            return widget.text().replace("&", "") == text

        widget = find_qt_widget(control, type_, test=test)
        if widget is None:
            # this will only occur if there is some problem with the test
            raise RuntimeError("Could not find matching child widget.")
        widget.click()

    def click_button(self, button_id):
        text = BUTTON_TEXT[button_id]
        self.click_widget(text)

    def value_assigned(self):
        """ A value was assigned to the result attribute.

        """
        result = self._assigned
        if result:
            # process any pending events so that we have a clean
            # even loop before we exit.
            self._helper.event_loop()
        return result

    def dialog_opened(self):
        """ Check that the dialog has opened.

        """
        dialog = self.get_dialog_widget()
        if dialog is None:
            return False
        if hasattr(dialog, "_ui"):
            # This is a traitsui dialog, we need one more check.
            ui = dialog._ui
            return ui.info.initialized
        else:
            # This is a simple QDialog.
            return dialog.isVisible()

    def get_dialog_widget(self):
        """ Get a reference to the active modal QDialog widget.

        """
        # It might make sense to also check for active window and active popup
        # window if this Tester is used for non-modal windows.
        return self._qt_app.activeModalWidget()

    def has_widget(self, text=None, type_=QtGui.QPushButton):
        """ Return true if there is a widget of `type_` with `text`.

        """
        if text is None:
            test = None
        else:
            test = lambda qwidget: qwidget.text() == text
        return self.find_qt_widget(type_=type_, test=test) is not None

    def find_qt_widget(self, type_=QtGui.QPushButton, test=None):
        """ Return the widget of `type_` for which `test` returns true.

        """
        if test is None:
            test = lambda x: True
        window = self.get_dialog_widget()
        return find_qt_widget(window, type_, test=test)