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 343 344 345 346 347 348 349 350 351 352 353
|
# (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!
import contextlib
import gc
import threading
import unittest.mock as mock
from pyface.qt.QtGui import QApplication
from pyface.ui.qt.gui import GUI
from traits.testing.api import UnittestTools
from traits.testing.unittest_tools import (
_TraitsChangeCollector as TraitsChangeCollector,
)
from .testing import find_qt_widget
from .event_loop_helper import EventLoopHelper, ConditionTimeoutError
class GuiTestAssistant(UnittestTools):
# 'TestCase' protocol -------------------------------------------------#
def setUp(self):
qt_app = QApplication.instance()
if qt_app is None:
qt_app = QApplication([])
self.qt_app = qt_app
self.gui = GUI()
self.event_loop_helper = EventLoopHelper(
qt_app=self.qt_app, gui=self.gui
)
try:
import traitsui.api # noqa: F401
except ImportError:
self.traitsui_raise_patch = None
else:
try:
import traitsui.qt # noqa: F401
self.traitsui_raise_patch = mock.patch(
"traitsui.qt.ui_base._StickyDialog.raise_"
)
except ModuleNotFoundError:
self.traitsui_raise_patch = mock.patch(
"traitsui.qt4.ui_base._StickyDialog.raise_"
)
self.traitsui_raise_patch.start()
def new_activate(self):
self.control.activateWindow()
self.pyface_raise_patch = mock.patch(
"pyface.ui.qt.window.Window.activate",
new_callable=lambda: new_activate,
)
self.pyface_raise_patch.start()
def tearDown(self):
# Process any tasks that a misbehaving test might have left on the
# queue.
with self.event_loop_with_timeout(repeat=5):
pass
# Some top-level widgets may only be present due to cyclic garbage not
# having been collected; force a garbage collection before we decide to
# close windows. This may need several rounds.
for _ in range(10):
if not gc.collect():
break
if len(self.qt_app.topLevelWidgets()) > 0:
with self.event_loop_with_timeout(repeat=5):
self.gui.invoke_later(self.qt_app.closeAllWindows)
self.pyface_raise_patch.stop()
if self.traitsui_raise_patch is not None:
self.traitsui_raise_patch.stop()
del self.pyface_raise_patch
del self.traitsui_raise_patch
del self.event_loop_helper
del self.gui
del self.qt_app
# 'GuiTestAssistant' protocol -----------------------------------------#
@contextlib.contextmanager
def event_loop(self, repeat=1):
"""Artificially replicate the event loop by Calling sendPostedEvents
and processEvents ``repeat`` number of times. If the events to be
processed place more events in the queue, begin increasing the value
of ``repeat``, or consider using ``event_loop_until_condition``
instead.
Parameters
----------
repeat : int
Number of times to process events.
"""
yield
self.event_loop_helper.event_loop(repeat=repeat)
@contextlib.contextmanager
def delete_widget(self, widget, timeout=1.0):
"""Runs the real Qt event loop until the widget provided has been
deleted.
Parameters
----------
widget : QObject
The widget whose deletion will stop the event loop.
timeout : float
Number of seconds to run the event loop in the case that the
widget is not deleted.
"""
try:
with self.event_loop_helper.delete_widget(widget, timeout=timeout):
yield
except ConditionTimeoutError:
self.fail(
"Could not destroy widget before timeout: {!r}".format(widget)
)
@contextlib.contextmanager
def event_loop_until_condition(self, condition, timeout=10.0):
"""Runs the real Qt event loop until the provided condition evaluates
to True.
This should not be used to wait for widget deletion. Use
delete_widget() instead.
Notes
-----
This runs the real Qt event loop, polling the condition every 50 ms and
returning as soon as the condition becomes true. If the condition does
not become true within the given timeout, a ConditionTimeoutError is
raised.
Because the state of the condition is only polled every 50 ms, it
may fail to detect transient states that appear and disappear within
a 50 ms window. Code should ensure that any state that is being
tested by the condition cannot revert to a False value once it becomes
True.
If the event loop exits before the condition evaluates to True or times
out, a RuntimeWarning will be generated and the message will indicate
whether the condition was ever successfully evaluated or whether it
always evalutated to False.
Parameters
----------
condition : Callable
A callable to determine if the stop criteria have been met. This
should accept no arguments.
timeout : float
Number of seconds to run the event loop in the case that the
condition is not satisfied.
`timeout` is rounded to the nearest millisecond.
"""
try:
yield
self.event_loop_helper.event_loop_until_condition(
condition, timeout=timeout
)
except ConditionTimeoutError:
self.fail("Timed out waiting for condition")
def assertEventuallyTrueInGui(self, condition, timeout=10.0):
"""
Assert that the given condition becomes true if we run the GUI
event loop for long enough.
Notes
-----
This assertion runs the real Qt event loop, polling the condition
every 50 ms and returning as soon as the condition becomes true. If
the condition does not become true within the given timeout, the
assertion fails.
Because the state of the condition is only polled every 50 ms, it
may fail to detect transient states that appear and disappear within
a 50 ms window. Tests should ensure that any state that is being
tested by the condition cannot revert to a False value once it becomes
True.
Parameters
----------
condition : Callable() -> bool
Callable accepting no arguments and returning a bool.
timeout : float
Maximum length of time to wait for the condition to become
true, in seconds.
Raises
------
self.failureException
If the condition does not become true within the given timeout.
"""
try:
self.event_loop_helper.event_loop_until_condition(
condition, timeout=timeout
)
except ConditionTimeoutError:
self.fail("Timed out waiting for condition to become true.")
@contextlib.contextmanager
def assertTraitChangesInEventLoop(
self, obj, trait, condition, count=1, timeout=10.0
):
"""Runs the real Qt event loop, collecting trait change events until
the provided condition evaluates to True.
Parameters
----------
obj : traits.has_traits.HasTraits
The HasTraits instance whose trait will change.
trait : str
The extended trait name of trait changes to listen to.
condition : Callable
A callable to determine if the stop criteria have been met. This
takes obj as the only argument.
count : int
The expected number of times the event should be fired. The default
is to expect one event.
timeout : float
Number of seconds to run the event loop in the case that the trait
change does not occur.
"""
condition_ = lambda: condition(obj)
collector = TraitsChangeCollector(obj=obj, trait_name=trait)
collector.start_collecting()
try:
try:
yield collector
self.event_loop_helper.event_loop_until_condition(
condition_, timeout=timeout
)
except ConditionTimeoutError:
actual_event_count = collector.event_count
msg = (
"Expected {} event on {} to be fired at least {} "
"times, but the event was only fired {} times "
"before timeout ({} seconds)."
)
msg = msg.format(
trait, obj, count, actual_event_count, timeout
)
self.fail(msg)
finally:
collector.stop_collecting()
@contextlib.contextmanager
def event_loop_until_traits_change(self, traits_object, *traits, **kw):
"""Run the real application event loop until a change notification for
all of the specified traits is received.
Paramaters
----------
traits_object : traits.has_traits.HasTraits
The object on which to listen for a trait events
traits : one or more str
The names of the traits to listen to for events
timeout : float, optional, keyword only
Number of seconds to run the event loop in the case that the trait
change does not occur. Default value is 10.0.
"""
timeout = kw.pop("timeout", 10.0)
condition = threading.Event()
traits = set(traits)
recorded_changes = set()
# Correctly handle the corner case where there are no traits.
if not traits:
condition.set()
def set_event(trait):
recorded_changes.add(trait)
if recorded_changes == traits:
condition.set()
def make_handler(trait):
def handler(event):
set_event(trait)
return handler
handlers = {trait: make_handler(trait) for trait in traits}
for trait, handler in handlers.items():
traits_object.observe(handler, trait)
try:
with self.event_loop_until_condition(
condition=condition.is_set, timeout=timeout
):
yield
finally:
for trait, handler in handlers.items():
traits_object.observe(handler, trait, remove=True)
@contextlib.contextmanager
def event_loop_with_timeout(self, repeat=2, timeout=10.0):
"""Helper context manager to send all posted events to the event queue
and wait for them to be processed.
This differs from the `event_loop()` context manager in that it
starts the real event loop rather than emulating it with
`QApplication.processEvents()`
Parameters
----------
repeat : int
Number of times to process events. Default is 2.
timeout : float, optional, keyword only
Number of seconds to run the event loop in the case that the trait
change does not occur. Default value is 10.0.
"""
yield
self.event_loop_helper.event_loop_with_timeout(
repeat=repeat, timeout=timeout
)
def find_qt_widget(self, start, type_, test=None):
"""Recursively walks the Qt widget tree from Qt widget `start` until it
finds a widget of type `type_` (a QWidget subclass) that
satisfies the provided `test` method.
Parameters
----------
start : QWidget
The widget from which to start walking the tree
type_ : type
A subclass of QWidget to use for an initial type filter while
walking the tree
test : Callable
A filter function that takes one argument (the current widget being
evaluated) and returns either True or False to determine if the
widget matches the required criteria.
"""
return find_qt_widget(start, type_, test=test)
|