From 4c1bddb410755835d0c9f108524d9198c80fe484 Mon Sep 17 00:00:00 2001 From: Kit Yan Choi Date: Mon, 1 Jun 2020 13:14:17 +0100 Subject: [PATCH 01/16] Draft API for UITester and BaseSimulator --- traitsui/testing/__init__.py | 0 traitsui/testing/api.py | 2 + traitsui/testing/exceptions.py | 10 + traitsui/testing/simulation.py | 389 +++++++++++++++++++++++ traitsui/testing/tests/__init__.py | 0 traitsui/testing/tests/test_ui_tester.py | 97 ++++++ traitsui/testing/ui_tester.py | 98 ++++++ traitsui/tests/_tools.py | 28 +- 8 files changed, 620 insertions(+), 4 deletions(-) create mode 100644 traitsui/testing/__init__.py create mode 100644 traitsui/testing/api.py create mode 100644 traitsui/testing/exceptions.py create mode 100644 traitsui/testing/simulation.py create mode 100644 traitsui/testing/tests/__init__.py create mode 100644 traitsui/testing/tests/test_ui_tester.py create mode 100644 traitsui/testing/ui_tester.py diff --git a/traitsui/testing/__init__.py b/traitsui/testing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/traitsui/testing/api.py b/traitsui/testing/api.py new file mode 100644 index 000000000..f9bb3ddba --- /dev/null +++ b/traitsui/testing/api.py @@ -0,0 +1,2 @@ +from traitsui.testing.ui_tester import UITester # noqa: F401 +from traitsui.testing.exceptions import Disabled # noqa: F401 diff --git a/traitsui/testing/exceptions.py b/traitsui/testing/exceptions.py new file mode 100644 index 000000000..832d97d60 --- /dev/null +++ b/traitsui/testing/exceptions.py @@ -0,0 +1,10 @@ + +class SimulationError(Exception): + """ Raised when simulating user interactions on GUI.""" + pass + + +class Disabled(SimulationError): + """ Raised when a simulation fails because the widget is disabled. + """ + pass diff --git a/traitsui/testing/simulation.py b/traitsui/testing/simulation.py new file mode 100644 index 000000000..207e06fef --- /dev/null +++ b/traitsui/testing/simulation.py @@ -0,0 +1,389 @@ + +class BaseSimulator: + """ The base class for all simulators to be used for simulating user + interactions with GUI components for testing TraitsUI and applications + written using TraitsUI. + + Each simulator should be associated with one or many specific Editor of + TraitsUI. + + Concrete implementations should aim at programmatically triggering UI + events by manipulating UI components, e.g. clicking a button, instead of + calling event handlers on an editor. + + Methods for simulating user interactions are optional. Whether they are + implemented depends on the context of an editor. + + A simulator targeting a DateEditor may support setting a date or a + text but not an index, whereas a simulator targeting an EnumEditor may + support setting a text and an index but not a date. + + Attributes + ---------- + editor : Editor + An instance of Editor. It is assumed to have a valid, non-None GUI + widget in its ``control`` attribute. + """ + + def __init__(self, editor): + self.editor = editor + + def get_ui(self): + """ Return an instance of traitsui.ui.UI for delegating actions to. + + Subclass may override this method, e.g. to delegate actions or queries + on a different simulator. Default implementation is to return + NotImplemented, and the original UI will be used. + + Returns + ------- + ui : traitsui.ui.UI or NotImplemeneted + """ + return NotImplemented + + def get_text(self): + """ Return the text value being presented by the editor. + + This is an optional method. Subclass may not have implemented this + method if it is not applicable for the editor. + + Returns + ------- + text : str + + Raises + ------ + SimulationError + """ + raise NotImplementedError( + "{!r} has not implemented 'get_text'.".format(self.__class__) + ) + + def set_text(self, text): + """ Set the text value for an editor. + + This is an optional method. Subclass may not have implemented this + method if it is not applicable for the editor. + + Parameters + ---------- + text : str + Text to be set on the GUI component. + + Raises + ------ + SimulationError + """ + raise NotImplementedError( + "{!r} has not implemented 'set_text'.".format(self.__class__) + ) + + def get_date(self): + """ Return the date value being presented by the editor. + + This is an optional method. Subclass may not have implemented this + method if it is not applicable for the editor. + + Returns + ------- + date : datetime.date + + Raises + ------ + SimulationError + """ + raise NotImplementedError( + "{!r} has not implemented 'get_date'.".format(self.__class__) + ) + + def set_date(self, date): + """ Set the date value for an editor. + + This is an optional method. Subclass may not have implemented this + method if it is not applicable for the editor. + + Parameters + ---------- + date : datetime.date + + Raises + ------ + SimulationError + """ + raise NotImplementedError( + "{!r} has not implemented 'set_date'.".format(self.__class__) + ) + + def click_date(self, date): + """ Perform a click event on the GUI component where it can be uniquely + identified by a date. + + This is an optional method. Subclass may not have implemented this + method if it is not applicable for the editor. + + Parameters + ---------- + date : datetime.date + + Raises + ------ + SimulationError + """ + raise NotImplementedError( + "{!r} has not implemented 'click_date'.".format(self.__class__) + ) + + def click(self): + """ Perform a click event on the editor. + + This is an optional method. Subclass may not have implemented this + method if it is not applicable for the editor. + + Raises + ------ + SimulationError + """ + raise NotImplementedError( + "{!r} has not implemented 'click'.".format(self.__class__) + ) + + def click_index(self, index): + """ Perform a click event on the GUI component where it can be uniquely + identified by a 0-based index. + + This is an optional method. Subclass may not have implemented this + method if it is not applicable for the editor. + + Parameters + ---------- + index : int + + Raises + ------ + SimulationError + """ + raise NotImplementedError( + "{!r} has not implemented 'click_index'.".format(self.__class__) + ) + + +# ----------------------------- +# Simulator registration +# ----------------------------- + +class SimulatorRegistry: + """ A registry for mapping toolkit-specific Editor class to a subclass + of BaseSimulator. + + When an instance of Editor is retrieved from a UI, this registry will + provide the simulator class approprites for simulating user actions. + """ + + def __init__(self): + self.editor_to_simulator = {} + + def register(self, editor_class, simulator_class): + """ Register a subclass of BaseSimulator to a subclass of Editor. + + Parameters + ---------- + editor_class : subclass of traitsui.editor.Editor + The Editor class to simulate. + simulator_class : subclass of BaseSimulator + The simulator class. + """ + + if editor_class in self.editor_to_simulator: + raise ValueError( + "{!r} was already registered.".format(editor_class) + ) + self.editor_to_simulator[editor_class] = simulator_class + + def unregister(self, editor_class, simulator_class=None): + """ Reverse the register action. + + Parameters + ---------- + editor_class : subclass of traitsui.editor.Editor + The Editor class to simulate. + simulator_class : subclass of BaseSimulator, optional. + The simulator class. If provided and the target simulator class + does not match the provided one, an error will be raised and + no unregistration is performed. + """ + to_be_removed = self.editor_to_simulator[editor_class] + if simulator_class is not None and to_be_removed is not simulator_class: + raise ValueError( + "Provided {!r} does not matched the registered {!r}".format( + simulator_class, to_be_removed + )) + del self.editor_to_simulator[editor_class] + + def get_simulator_class(self, editor_class): + """ Retrieve the simulator class for a given instance of Editor + subclass. + + Parameters + ---------- + editor_class : subclass of traitsui.editor.Editor + The Editor class to simulate. + + Raises + ------ + KeyError + """ + try: + return self.editor_to_simulator[editor_class] + except KeyError: + # Re-raise for a better error message. + raise KeyError( + "No simulators can be found for {!r}".format(editor_class) + ) from None + + +#: Default registry for providing default simulators. +REGISTRY = SimulatorRegistry() + + +def simulate(editor_class, registry=REGISTRY): + """ Decorator for registering a subclass of BaseSimulator for simulating + a particular subclass of Editor. + + Parameters + ---------- + editor_class : subclass of traitsui.editor.Editor + The Editor class to simulate. + registry : SimulatorRegistry, optional + Registry to used. Default is to use a global registry provided + by TraitsUI. + To undo a registration, see ``SimulatorRegistry.unregister``. + """ + def wrapper(simulator_class): + registry.register(editor_class, simulator_class) + return simulator_class + return wrapper + + +def set_editor_value(ui, name, setter, registry=REGISTRY): + """ Perform actions to modify GUI components. + + Parameters + ---------- + ui : traitsui.ui.UI + The UI from which an editor will be retrieved. + name : str + An extended name for retreiving an editor on a UI. + e.g. "model.attr1.attr2" + setter : callable(BaseSimulator) + Callable to perform simulation. + registry : SimulatorRegistry, optional + The registry from which to find a BaseSimulator for the retrieved + editor. + """ + simulator, name = _get_one_simulator(ui=ui, name=name, registry=registry) + alternative_ui = simulator.get_ui() + if alternative_ui is not NotImplemented: + set_editor_value( + ui=alternative_ui, + name=name, + getter=getter, + registry=registry, + ) + else: + setter(simulator) + + +def get_editor_value(ui, name, getter, registry=REGISTRY): + """ Perform a query on GUI components for inspection purposes. + + Parameters + ---------- + ui : traitsui.ui.UI + The UI from which an editor will be retrieved. + name : str + An extended name for retreiving an editor on a UI. + e.g. "model.attr1.attr2" + getter : callable(BaseSimulator) -> any + Callable to retrieve value or values from the GUI. + registry : SimulatorRegistry, optional + The registry from which to find a BaseSimulator for the retrieved + editor. + + Returns + ------- + value : any + Any value returned by the getter. + """ + simulator, name = _get_one_simulator(ui=ui, name=name, registry=registry) + alternative_ui = simulator.get_ui() + if alternative_ui is not NotImplemented: + return get_editor_value( + ui=alternative_ui, + name=name, + getter=getter, + registry=registry, + ) + return getter(simulator) + + +def _get_one_simulator(ui, name, registry=REGISTRY): + """ Return one instance of BaseSimulation for an editor uniquely identified + from the given UI and name. + + Parameters + ---------- + ui : traitsui.ui.UI + The UI from which an editor will be retrieved. + name : str + A single or an extended name for retreiving an editor on a UI. + e.g. "attr", "model.attr1.attr2" + registry : SimulatorRegistry, optional + The registry from which to find a BaseSimulator for the retrieved + editor. + + Returns + ------- + simulator : BaseSimulator + Simulator for the editor found. + name : str + Modified name if the original name is an extended name. + + Raises + ------ + ValuError + If zero or more than one editors are found. + """ + simulators, new_name = _get_simulators(ui=ui, name=name, registry=registry) + + if not simulators: + raise ValueError( + "No editors can be found with name {!r}".format(name) + ) + if len(simulators) > 1: + raise ValueError("Found multiple editors with name {!r}.".format(name)) + + simulator, = simulators + return simulator, new_name + + +def _get_simulators(ui, name, registry=REGISTRY): + """ Return instances of BaseSimulator from an instance of traitsui.ui.UI + with a given extended name. + + Parameters + ---------- + ui : traitsui.ui.UI + name : str + + """ + if "." in name: + editor_name, name = name.split(".", 1) + else: + editor_name = name + editors = ui.get_editors(editor_name) + + simulators = [ + registry.get_simulator_class(editor)(editor) + for editor in editors + ] + return simulators, name diff --git a/traitsui/testing/tests/__init__.py b/traitsui/testing/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/traitsui/testing/tests/test_ui_tester.py b/traitsui/testing/tests/test_ui_tester.py new file mode 100644 index 000000000..1c07d40cb --- /dev/null +++ b/traitsui/testing/tests/test_ui_tester.py @@ -0,0 +1,97 @@ +import datetime +import unittest + +from traits.api import ( + Bool, Button, Date, Enum, Instance, HasTraits, List, Str, Int, + on_trait_change, Property +) +from traitsui.api import ModelView, View, Item, CheckListEditor +from traitsui.testing.api import Disabled, UITester +from traitsui.tests._tools import ( + requires_one_of, + QT, + WX, +) + + +class Parent(HasTraits): + + first_name = Str() + last_name = Str() + age = Int() + employment_status = Enum(["employed", "unemployed"]) + + +class Child(HasTraits): + + mother = Instance(Parent) + father = Instance(Parent) + first_name = Str() + last_name = Property(depends_on="father,father.last_name") + + def _get_last_name(self): + if self.father is None: + return None + return self.father.last_name + + +class SimpleApplication(ModelView): + + model = Instance(Child) + + +@requires_one_of([QT, WX]) +class TestUITesterIntegration(unittest.TestCase): + """ Integration tests for UITester. + + These tests are close imitations for how UITester is expected to be used. + """ + + def setUp(self): + # UITest can also be used as a context manager + self.tester = UITester() + self.tester.start() + + def tearDown(self): + self.tester.stop() + + def test_instance_text_editor_from_view(self): + # Test popagating commands to instance simple/custom editors + father = Parent(first_name="M", last_name="C") + mother = Parent(first_name="J", last_name="E", age=50) + child = Child(mother=mother, father=father) + app = SimpleApplication(model=child) + with self.tester.create_ui(app) as ui: + self.tester.set_text(ui, "father.first_name", "B") + self.assertEqual(app.model.father.first_name, "B") + + self.tester.set_text(ui, "mother.age", "A") # invalid + self.assertEqual(app.model.mother.age, 50) + + self.tester.set_text(ui, "mother.age", "60") # valid + self.assertEqual(app.model.mother.age, 60) + + self.tester.set_text(ui, "father.employment_status", "unemployed") + self.assertEqual(app.model.father.employment_status, "unemployed") + + self.tester.click_index(ui, "mother.employment_status", 1) + self.assertEqual(app.model.mother.employment_status, "unemployed") + + def test_instance_text_editor_query(self): + # Test popagating queries to instance simple/custom editors + father = Parent(first_name="M", last_name="C") + mother = Parent(first_name="J", last_name="E", age=50) + child = Child(mother=mother, father=father) + app = SimpleApplication(model=child) + with self.tester.create_ui(app) as ui: + app.model.father.first_name = "B" + actual = self.tester.get_text(ui, "father.first_name") + self.assertEqual(actual, "B") + + app.model.mother.age = 60 + actual = self.tester.get_text(ui, "mother.age") + self.assertEqual(actual, "60") + + app.model.father.employment_status = "unemployed" + actual = self.tester.get_text(ui, "father.employment_status") + self.assertEqual(actual, "unemployed") diff --git a/traitsui/testing/ui_tester.py b/traitsui/testing/ui_tester.py new file mode 100644 index 000000000..61db9bb8e --- /dev/null +++ b/traitsui/testing/ui_tester.py @@ -0,0 +1,98 @@ + +from contextlib import contextmanager + +from pyface.gui import GUI + +from traitsui.testing.simulation import ( + get_editor_value, set_editor_value, REGISTRY +) +from traitsui.tests._tools import store_exceptions_on_all_threads + + +class UITester: + + def __init__(self, registry=REGISTRY): + self.gui = None + self.registry = registry + + def start(self): + if self.gui is None: + self.gui = GUI() + + def stop(self): + if self.gui is not None: + with store_exceptions_on_all_threads(): + self.gui.process_events() + self.gui = None + + @contextmanager + def create_ui(self, object, ui_kwargs=None): + self._ensure_started() + if ui_kwargs is None: + ui_kwargs = {} + ui = object.edit_traits(**ui_kwargs) + try: + yield ui + finally: + ui.dispose() + + def _set_editor_value(self, ui, name, setter): + self._ensure_started() + with store_exceptions_on_all_threads(): + set_editor_value(ui, name, setter, registry=self.registry) + self.gui.process_events() + + def _get_editor_value(self, ui, name, getter): + self._ensure_started() + with store_exceptions_on_all_threads(): + self.gui.process_events() + return get_editor_value(ui, name, getter, registry=self.registry) + + def get_text(self, ui, name): + return self._get_editor_value( + ui=ui, name=name, getter=lambda s: s.get_text() + ) + + def set_text(self, ui, name, text, confirmed=True): + self._set_editor_value( + ui=ui, + name=name, + setter=lambda s: s.set_text(text, confirmed=confirmed) + ) + + def get_date(self, ui, name): + return self._get_editor_value( + ui=ui, name=name, getter=lambda s: s.get_date() + ) + + def set_date(self, ui, name, date): + self._set_editor_value( + ui=ui, name=name, setter=lambda s: s.set_date(date) + ) + + def click_date(self, ui, name, date): + self._set_editor_value( + ui=ui, name=name, setter=lambda s: s.click_date(date) + ) + + def click(self, ui, name): + self._set_editor_value( + ui=ui, name=name, setter=lambda s: s.click() + ) + + def click_index(self, ui, name, index): + self._set_editor_value( + ui=ui, name=name, setter=lambda s: s.click_index(index) + ) + + def _ensure_started(self): + if self.gui is None: + raise ValueError( + "'start' has not been called on {!r}.".format(self)) + + def __enter__(self): + self.start() + return self + + def __exit__(self, *args, **kwargs): + self.stop() diff --git a/traitsui/tests/_tools.py b/traitsui/tests/_tools.py index 8fbac4a6a..aaa095240 100644 --- a/traitsui/tests/_tools.py +++ b/traitsui/tests/_tools.py @@ -19,7 +19,7 @@ import traceback from functools import partial from contextlib import contextmanager -from unittest import skipIf, TestSuite +from unittest import skip, skipIf, TestSuite from pyface.toolkit import toolkit_object from traits.etsconfig.api import ETSConfig @@ -27,6 +27,11 @@ # ######### Testing tools +# Toolkit names as are used by ETSConfig +WX = "wx" +QT = "qt4" +NULL = "null" + @contextmanager def store_exceptions_on_all_threads(): @@ -71,13 +76,13 @@ def _is_current_backend(backend_name=""): #: Return True if current backend is 'wx' -is_current_backend_wx = partial(_is_current_backend, backend_name="wx") +is_current_backend_wx = partial(_is_current_backend, backend_name=WX) #: Return True if current backend is 'qt4' -is_current_backend_qt4 = partial(_is_current_backend, backend_name="qt4") +is_current_backend_qt4 = partial(_is_current_backend, backend_name=QT) #: Return True if current backend is 'null' -is_current_backend_null = partial(_is_current_backend, backend_name="null") +is_current_backend_null = partial(_is_current_backend, backend_name=NULL) #: Test decorator: Skip test if backend is not 'wx' @@ -99,6 +104,21 @@ def _is_current_backend(backend_name=""): is_mac_os = sys.platform == "Darwin" +def requires_one_of(backends): + + def decorator(test_item): + + if ETSConfig.toolkit not in backends: + return skip( + "Test only support these backends: {!r}".format(backends) + )(test_item) + + else: + return test_item + + return decorator + + def count_calls(func): """Decorator that stores the number of times a function is called. From bede552fca63e62ecba1dc0f9fec4341da3b0d66 Mon Sep 17 00:00:00 2001 From: Kit Yan Choi Date: Mon, 1 Jun 2020 14:42:58 +0100 Subject: [PATCH 02/16] Implement simulation for Qt tests --- traitsui/qt4/enum_editor.py | 20 ++++++++ traitsui/qt4/instance_editor.py | 23 +++++++++ traitsui/qt4/text_editor.py | 19 +++++++ traitsui/testing/api.py | 4 ++ traitsui/testing/simulation.py | 65 ++++++++++++++---------- traitsui/testing/tests/test_ui_tester.py | 28 ++++++++-- 6 files changed, 128 insertions(+), 31 deletions(-) diff --git a/traitsui/qt4/enum_editor.py b/traitsui/qt4/enum_editor.py index d017030b9..149da5c33 100644 --- a/traitsui/qt4/enum_editor.py +++ b/traitsui/qt4/enum_editor.py @@ -30,6 +30,7 @@ from .constants import OKColor, ErrorColor from .editor import Editor +from traitsui.testing.api import BaseSimulator, Disabled, simulate # default formatting function (would import from string, but not in Python 3) capitalize = lambda s: s.capitalize() @@ -299,6 +300,25 @@ def update_autoset_text_object(self): return self.update_text_object(text) +@simulate(SimpleEditor) +class SimpleEnumEditorSimulator(BaseSimulator): + + def click_index(self, index): + self.editor.control.setCurrentIndex(index) + + def set_text(self, text, confirmed=True): + if not self.editor.control.isEditable(): + raise Disabled("This combox box is not editable by text.") + + self.editor.control.setEditText(text) + line_edit = self.editor.control.lineEdit() + if line_edit is not None and confirmed: + line_edit.editingFinished.emit() + + def get_text(self): + return self.editor.control.currentText() + + class RadioEditor(BaseEditor): """ Enumeration editor, used for the "custom" style, that displays radio buttons. diff --git a/traitsui/qt4/instance_editor.py b/traitsui/qt4/instance_editor.py index 71a31b886..d1a873052 100644 --- a/traitsui/qt4/instance_editor.py +++ b/traitsui/qt4/instance_editor.py @@ -14,6 +14,7 @@ the PyQt user interface toolkit.. """ +import contextlib from pyface.qt import QtCore, QtGui @@ -32,6 +33,8 @@ from .constants import DropColor from .helper import position_window +from traitsui.testing.api import BaseSimulator, simulate + OrientationMap = { "default": None, @@ -396,6 +399,14 @@ def _view_changed(self, view): self.resynch_editor() +@simulate(CustomEditor) +class CustomEditorSimulator(BaseSimulator): + + @contextlib.contextmanager + def get_ui(self): + yield self.editor._ui + + class SimpleEditor(CustomEditor): """ Simple style of editor for instances, which displays a button. Clicking the button displays a dialog box in which the instance can be edited. @@ -463,3 +474,15 @@ def _parent_closed(self): self._dialog_ui.control.close() self._dialog_ui.dispose() self._dialog_ui = None + + +@simulate(SimpleEditor) +class SimpleInstanceEditorSimulator(BaseSimulator): + + @contextlib.contextmanager + def get_ui(self): + self.editor._button.click() + try: + yield self.editor._dialog_ui + finally: + self.editor._dialog_ui.dispose() diff --git a/traitsui/qt4/text_editor.py b/traitsui/qt4/text_editor.py index 5c5c5e088..b83f22fe7 100644 --- a/traitsui/qt4/text_editor.py +++ b/traitsui/qt4/text_editor.py @@ -29,6 +29,7 @@ from .constants import OKColor +from traitsui.testing.api import BaseSimulator, Disabled, simulate class SimpleEditor(Editor): @@ -182,6 +183,24 @@ class CustomEditor(SimpleEditor): base_style = QtGui.QTextEdit +@simulate(CustomEditor) +@simulate(SimpleEditor) +class TextEditorSimulator(BaseSimulator): + + def set_text(self, text, confirmed=True): + if not self.editor.control.isEnabled(): + raise Disabled("Text field is disabled.") + + self.editor.control.setText(text) + + factory = self.editor.factory + if factory.auto_set and not factory.is_grid_cell and confirmed: + self.editor.control.textEdited.emit(text) + + def get_text(self): + return self.editor.control.text() + + class ReadonlyEditor(BaseReadonlyEditor): """ Read-only style of text editor, which displays a read-only text field. """ diff --git a/traitsui/testing/api.py b/traitsui/testing/api.py index f9bb3ddba..a3e8c1ea9 100644 --- a/traitsui/testing/api.py +++ b/traitsui/testing/api.py @@ -1,2 +1,6 @@ from traitsui.testing.ui_tester import UITester # noqa: F401 from traitsui.testing.exceptions import Disabled # noqa: F401 +from traitsui.testing.simulation import ( + BaseSimulator, + simulate, +) \ No newline at end of file diff --git a/traitsui/testing/simulation.py b/traitsui/testing/simulation.py index 207e06fef..86556747e 100644 --- a/traitsui/testing/simulation.py +++ b/traitsui/testing/simulation.py @@ -1,4 +1,7 @@ +import contextlib + + class BaseSimulator: """ The base class for all simulators to be used for simulating user interactions with GUI components for testing TraitsUI and applications @@ -28,18 +31,21 @@ class BaseSimulator: def __init__(self, editor): self.editor = editor + @contextlib.contextmanager def get_ui(self): - """ Return an instance of traitsui.ui.UI for delegating actions to. + """ A context manager to yield an instance of traitsui.ui.UI for + delegating actions to. Subclass may override this method, e.g. to delegate actions or queries - on a different simulator. Default implementation is to return - NotImplemented, and the original UI will be used. + on a different simulator. Default implementation is to yield + NotImplemented and perform no additional clean ups; the original UI + will be used. - Returns - ------- + Yields + ------ ui : traitsui.ui.UI or NotImplemeneted """ - return NotImplemented + yield NotImplemented def get_text(self): """ Return the text value being presented by the editor. @@ -59,7 +65,7 @@ def get_text(self): "{!r} has not implemented 'get_text'.".format(self.__class__) ) - def set_text(self, text): + def set_text(self, text, confirmed=True): """ Set the text value for an editor. This is an optional method. Subclass may not have implemented this @@ -69,6 +75,10 @@ def set_text(self, text): ---------- text : str Text to be set on the GUI component. + confirmed : boolean, optional + Whether the text change is confirmed. Useful for testing the absent + of events when ``auto_set`` is set to false. Default is to confirm + the change. Raises ------ @@ -281,16 +291,16 @@ def set_editor_value(ui, name, setter, registry=REGISTRY): editor. """ simulator, name = _get_one_simulator(ui=ui, name=name, registry=registry) - alternative_ui = simulator.get_ui() - if alternative_ui is not NotImplemented: - set_editor_value( - ui=alternative_ui, - name=name, - getter=getter, - registry=registry, - ) - else: - setter(simulator) + with simulator.get_ui() as alternative_ui: + if alternative_ui is not NotImplemented: + set_editor_value( + ui=alternative_ui, + name=name, + setter=setter, + registry=registry, + ) + else: + setter(simulator) def get_editor_value(ui, name, getter, registry=REGISTRY): @@ -315,15 +325,15 @@ def get_editor_value(ui, name, getter, registry=REGISTRY): Any value returned by the getter. """ simulator, name = _get_one_simulator(ui=ui, name=name, registry=registry) - alternative_ui = simulator.get_ui() - if alternative_ui is not NotImplemented: - return get_editor_value( - ui=alternative_ui, - name=name, - getter=getter, - registry=registry, - ) - return getter(simulator) + with simulator.get_ui() as alternative_ui: + if alternative_ui is not NotImplemented: + return get_editor_value( + ui=alternative_ui, + name=name, + getter=getter, + registry=registry, + ) + return getter(simulator) def _get_one_simulator(ui, name, registry=REGISTRY): @@ -374,7 +384,6 @@ def _get_simulators(ui, name, registry=REGISTRY): ---------- ui : traitsui.ui.UI name : str - """ if "." in name: editor_name, name = name.split(".", 1) @@ -383,7 +392,7 @@ def _get_simulators(ui, name, registry=REGISTRY): editors = ui.get_editors(editor_name) simulators = [ - registry.get_simulator_class(editor)(editor) + registry.get_simulator_class(editor.__class__)(editor) for editor in editors ] return simulators, name diff --git a/traitsui/testing/tests/test_ui_tester.py b/traitsui/testing/tests/test_ui_tester.py index 1c07d40cb..55f597da7 100644 --- a/traitsui/testing/tests/test_ui_tester.py +++ b/traitsui/testing/tests/test_ui_tester.py @@ -5,7 +5,7 @@ Bool, Button, Date, Enum, Instance, HasTraits, List, Str, Int, on_trait_change, Property ) -from traitsui.api import ModelView, View, Item, CheckListEditor +from traitsui.api import EnumEditor, Item, ModelView, View from traitsui.testing.api import Disabled, UITester from traitsui.tests._tools import ( requires_one_of, @@ -21,6 +21,20 @@ class Parent(HasTraits): age = Int() employment_status = Enum(["employed", "unemployed"]) + def default_traits_view(self): + return View( + Item("first_name"), + Item("last_name"), + Item("age"), + Item( + "employment_status", + editor=EnumEditor( + values=["employed", "unemployed"], + evaluate=True, + ) + ), + ) + class Child(HasTraits): @@ -60,8 +74,12 @@ def test_instance_text_editor_from_view(self): father = Parent(first_name="M", last_name="C") mother = Parent(first_name="J", last_name="E", age=50) child = Child(mother=mother, father=father) + view = View( + Item("model.father", style="simple"), + Item("model.mother", style="custom"), + ) app = SimpleApplication(model=child) - with self.tester.create_ui(app) as ui: + with self.tester.create_ui(app, dict(view=view)) as ui: self.tester.set_text(ui, "father.first_name", "B") self.assertEqual(app.model.father.first_name, "B") @@ -83,7 +101,11 @@ def test_instance_text_editor_query(self): mother = Parent(first_name="J", last_name="E", age=50) child = Child(mother=mother, father=father) app = SimpleApplication(model=child) - with self.tester.create_ui(app) as ui: + view = View( + Item("model.father", style="simple"), + Item("model.mother", style="custom"), + ) + with self.tester.create_ui(app, dict(view=view)) as ui: app.model.father.first_name = "B" actual = self.tester.get_text(ui, "father.first_name") self.assertEqual(actual, "B") From 3155ab7b64efc1f81b0b972d0c58a263ecd58f4a Mon Sep 17 00:00:00 2001 From: Kit Yan Choi Date: Mon, 1 Jun 2020 14:49:26 +0100 Subject: [PATCH 03/16] Add class docstring --- traitsui/qt4/enum_editor.py | 4 ++++ traitsui/qt4/text_editor.py | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/traitsui/qt4/enum_editor.py b/traitsui/qt4/enum_editor.py index 149da5c33..b0ccd2f0d 100644 --- a/traitsui/qt4/enum_editor.py +++ b/traitsui/qt4/enum_editor.py @@ -302,6 +302,10 @@ def update_autoset_text_object(self): @simulate(SimpleEditor) class SimpleEnumEditorSimulator(BaseSimulator): + """ A simulator for testing GUI components with the simple EnumEditor. + + See ``traitsui.testing.api``. + """ def click_index(self, index): self.editor.control.setCurrentIndex(index) diff --git a/traitsui/qt4/text_editor.py b/traitsui/qt4/text_editor.py index b83f22fe7..89d465e82 100644 --- a/traitsui/qt4/text_editor.py +++ b/traitsui/qt4/text_editor.py @@ -186,6 +186,11 @@ class CustomEditor(SimpleEditor): @simulate(CustomEditor) @simulate(SimpleEditor) class TextEditorSimulator(BaseSimulator): + """ A simulator for testing GUI components with the simple and custom + styled TextEditor. + + See ``traitsui.testing.api``. + """ def set_text(self, text, confirmed=True): if not self.editor.control.isEnabled(): From 0eeac2ac58f8e6e26e50f7d7d148a0ef267c23ea Mon Sep 17 00:00:00 2001 From: Kit Yan Choi Date: Mon, 1 Jun 2020 14:51:13 +0100 Subject: [PATCH 04/16] More class docstring --- traitsui/qt4/instance_editor.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/traitsui/qt4/instance_editor.py b/traitsui/qt4/instance_editor.py index d1a873052..eaa436707 100644 --- a/traitsui/qt4/instance_editor.py +++ b/traitsui/qt4/instance_editor.py @@ -400,7 +400,10 @@ def _view_changed(self, view): @simulate(CustomEditor) -class CustomEditorSimulator(BaseSimulator): +class CustomInstanceEditorSimulator(BaseSimulator): + """ A simulator for custom instance editor: it delegates commands and + queries to the internal UI panel. + """ @contextlib.contextmanager def get_ui(self): @@ -478,6 +481,9 @@ def _parent_closed(self): @simulate(SimpleEditor) class SimpleInstanceEditorSimulator(BaseSimulator): + """ A simulator for simple instance editor. It launches the dialog and + delegates commands and queries to the dialog UI. + """ @contextlib.contextmanager def get_ui(self): From 1d8033f1c7b37e609c4659dbfb596ab44589e374 Mon Sep 17 00:00:00 2001 From: Kit Yan Choi Date: Mon, 1 Jun 2020 15:10:14 +0100 Subject: [PATCH 05/16] End-of-file new line --- traitsui/testing/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/traitsui/testing/api.py b/traitsui/testing/api.py index a3e8c1ea9..baaf8fb0e 100644 --- a/traitsui/testing/api.py +++ b/traitsui/testing/api.py @@ -3,4 +3,4 @@ from traitsui.testing.simulation import ( BaseSimulator, simulate, -) \ No newline at end of file +) From 18cfde346e74d4fb4fd95f5f10be8fe63131c336 Mon Sep 17 00:00:00 2001 From: Kit Yan Choi Date: Mon, 1 Jun 2020 15:27:19 +0100 Subject: [PATCH 06/16] More docstrings and cleanup --- traitsui/testing/ui_tester.py | 194 +++++++++++++++++++++++++++++++--- 1 file changed, 178 insertions(+), 16 deletions(-) diff --git a/traitsui/testing/ui_tester.py b/traitsui/testing/ui_tester.py index 61db9bb8e..aea4eb222 100644 --- a/traitsui/testing/ui_tester.py +++ b/traitsui/testing/ui_tester.py @@ -10,23 +10,68 @@ class UITester: + """ This tester is a public API for assisting GUI testing with TraitsUI. + + An instance of UITester can be instantiated inside a test and then be + used to drive changes on a Traits application via GUI components, imitating + user interactions. + + Several inspection methods are also supported. + + ``UITester`` can be used as a context manager. Alternatively its ``start`` + and ``stop`` methods can be used in a test's set up and tear down code. + """ def __init__(self, registry=REGISTRY): + """ Initialize a tester for testing GUI and traits interaction. + + Parameters + ---------- + registry : SimulatorRegistry, optional + A registry of simulators for different editors. + Default is a registry provided by TraitsUI. + """ self.gui = None self.registry = registry def start(self): + """ Start GUI testing. + """ if self.gui is None: self.gui = GUI() def stop(self): + """ Stop GUI testing and perform clean up. + """ if self.gui is not None: with store_exceptions_on_all_threads(): self.gui.process_events() self.gui = None + def __enter__(self): + self.start() + return self + + def __exit__(self, *args, **kwargs): + self.stop() + @contextmanager def create_ui(self, object, ui_kwargs=None): + """ Context manager to create a UI and dispose it upon exit. + + ``start`` must have been called prior to calling this method. + + Parameters + ---------- + object : HasTraits + An instance of HasTraits for which a GUI will be created. + ui_kwargs : dict, or None + Keyword arguments to be provided to ``HasTraits.edit_traits``. + + Yields + ------ + ui : traitsui.ui.UI + """ self._ensure_started() if ui_kwargs is None: ui_kwargs = {} @@ -36,24 +81,52 @@ def create_ui(self, object, ui_kwargs=None): finally: ui.dispose() - def _set_editor_value(self, ui, name, setter): - self._ensure_started() - with store_exceptions_on_all_threads(): - set_editor_value(ui, name, setter, registry=self.registry) - self.gui.process_events() + def get_text(self, ui, name): + """ Retrieve a text displayed on the GUI component uniquely identified + by a name (or extended name). - def _get_editor_value(self, ui, name, getter): - self._ensure_started() - with store_exceptions_on_all_threads(): - self.gui.process_events() - return get_editor_value(ui, name, getter, registry=self.registry) + This method may not be implemented by editors that do not support + text editing. - def get_text(self, ui, name): + Parameters + ---------- + ui : traitsui.ui.UI + The UI created, e.g. by ``create_ui``. + name : str + A single or an extended name for retreiving an editor on a UI. + e.g. "attr", "model.attr1.attr2" + + Returns + ------- + text : str + """ return self._get_editor_value( ui=ui, name=name, getter=lambda s: s.get_text() ) def set_text(self, ui, name, text, confirmed=True): + """ Set a text on the GUI component uniquely identified by a name (or + extended name). + + This method may not be implemented by editors that do not support + text editing. + + Parameters + ---------- + ui : traitsui.ui.UI + The UI created, e.g. by ``create_ui``. + name : str + A single or an extended name for retreiving an editor on a UI. + e.g. "attr", "model.attr1.attr2" + text : str + The text to be set. + confirmed : boolean, optional + Whether the change is confirmed. e.g. a text editor may require + the user to hit the return key in order to register the change. + Useful for testing the absence of events when an editor is + configured such that no events are fired until the user confirms + the change. + """ self._set_editor_value( ui=ui, name=name, @@ -61,38 +134,127 @@ def set_text(self, ui, name, text, confirmed=True): ) def get_date(self, ui, name): + """ Retrieve the date displayed on the GUI component uniquely + identified by a name (or extended name). + + This method may not be implemented by editors that do not support + the representation of dates. + + Parameters + ---------- + ui : traitsui.ui.UI + The UI created, e.g. by ``create_ui``. + name : str + A single or an extended name for retreiving an editor on a UI. + e.g. "attr", "model.attr1.attr2" + + Returns + ------- + date : datetime.date + """ return self._get_editor_value( ui=ui, name=name, getter=lambda s: s.get_date() ) def set_date(self, ui, name, date): + """ Set a date on the GUI component uniquely identified by a name (or + extended name). + + This method may not be implemented by editors that do not support + the representation of dates. + + Parameters + ---------- + ui : traitsui.ui.UI + The UI created, e.g. by ``create_ui``. + name : str + A single or an extended name for retreiving an editor on a UI. + e.g. "attr", "model.attr1.attr2" + date : datetime.date + The date to be set. + """ self._set_editor_value( ui=ui, name=name, setter=lambda s: s.set_date(date) ) def click_date(self, ui, name, date): + """ Perform a click (or toggle) action a GUI component with the given + date. + + This method may not be implemented by editors that do not support + the representation of dates. + + Parameters + ---------- + ui : traitsui.ui.UI + The UI created, e.g. by ``create_ui``. + name : str + A single or an extended name for retreiving an editor on a UI. + e.g. "attr", "model.attr1.attr2" + date : datetime.date + The date to be set. + """ self._set_editor_value( ui=ui, name=name, setter=lambda s: s.click_date(date) ) def click(self, ui, name): + """ Perform a click (or toggle) action on a GUI component uniquely + identified by the given name (or extended name). + + This method may not be implemented by editors that do not respond to + click events. + + Parameters + ---------- + ui : traitsui.ui.UI + The UI created, e.g. by ``create_ui``. + name : str + A single or an extended name for retreiving an editor on a UI. + e.g. "attr", "model.attr1.attr2" + """ self._set_editor_value( ui=ui, name=name, setter=lambda s: s.click() ) def click_index(self, ui, name, index): + """ Perform a click (or toggle) action on a GUI component uniquely + identified by the given name (or extended name). The index should + uniquely define where the click should occur in the context of the + GUI component. + + This method may not be implemented by editors that do not respond to + click events or editors that do not handle sequences. + + Parameters + ---------- + ui : traitsui.ui.UI + The UI created, e.g. by ``create_ui``. + name : str + A single or an extended name for retreiving an editor on a UI. + e.g. "attr", "model.attr1.attr2" + index : int + A 0-based index to indicate where the click event should occur. + """ self._set_editor_value( ui=ui, name=name, setter=lambda s: s.click_index(index) ) + # Private methods + def _ensure_started(self): if self.gui is None: raise ValueError( "'start' has not been called on {!r}.".format(self)) - def __enter__(self): - self.start() - return self + def _set_editor_value(self, ui, name, setter): + self._ensure_started() + with store_exceptions_on_all_threads(): + set_editor_value(ui, name, setter, registry=self.registry) + self.gui.process_events() - def __exit__(self, *args, **kwargs): - self.stop() + def _get_editor_value(self, ui, name, getter): + self._ensure_started() + with store_exceptions_on_all_threads(): + self.gui.process_events() + return get_editor_value(ui, name, getter, registry=self.registry) From e5b46a888e27d16f1d32a79853699ed7d141d131 Mon Sep 17 00:00:00 2001 From: Kit Yan Choi Date: Mon, 1 Jun 2020 17:00:11 +0100 Subject: [PATCH 07/16] Implementation for wx tests --- traitsui/testing/simulation.py | 12 +++++- traitsui/testing/tests/test_ui_tester.py | 1 + traitsui/testing/ui_tester.py | 6 ++- traitsui/wx/enum_editor.py | 44 ++++++++++++++++++++ traitsui/wx/instance_editor.py | 51 ++++++++++++++++++++---- traitsui/wx/text_editor.py | 31 +++++++++++++- 6 files changed, 132 insertions(+), 13 deletions(-) diff --git a/traitsui/testing/simulation.py b/traitsui/testing/simulation.py index 86556747e..fcff68e1f 100644 --- a/traitsui/testing/simulation.py +++ b/traitsui/testing/simulation.py @@ -274,7 +274,7 @@ def wrapper(simulator_class): return wrapper -def set_editor_value(ui, name, setter, registry=REGISTRY): +def set_editor_value(ui, name, setter, gui, registry=REGISTRY): """ Perform actions to modify GUI components. Parameters @@ -286,6 +286,8 @@ def set_editor_value(ui, name, setter, registry=REGISTRY): e.g. "model.attr1.attr2" setter : callable(BaseSimulator) Callable to perform simulation. + gui : pyface.gui.GUI + Object for driving the GUI event loop. registry : SimulatorRegistry, optional The registry from which to find a BaseSimulator for the retrieved editor. @@ -297,13 +299,15 @@ def set_editor_value(ui, name, setter, registry=REGISTRY): ui=alternative_ui, name=name, setter=setter, + gui=gui, registry=registry, ) else: setter(simulator) + gui.process_events() -def get_editor_value(ui, name, getter, registry=REGISTRY): +def get_editor_value(ui, name, getter, gui, registry=REGISTRY): """ Perform a query on GUI components for inspection purposes. Parameters @@ -315,6 +319,8 @@ def get_editor_value(ui, name, getter, registry=REGISTRY): e.g. "model.attr1.attr2" getter : callable(BaseSimulator) -> any Callable to retrieve value or values from the GUI. + gui : pyface.gui.GUI + Object for driving the GUI event loop. registry : SimulatorRegistry, optional The registry from which to find a BaseSimulator for the retrieved editor. @@ -326,11 +332,13 @@ def get_editor_value(ui, name, getter, registry=REGISTRY): """ simulator, name = _get_one_simulator(ui=ui, name=name, registry=registry) with simulator.get_ui() as alternative_ui: + gui.process_events() if alternative_ui is not NotImplemented: return get_editor_value( ui=alternative_ui, name=name, getter=getter, + gui=gui, registry=registry, ) return getter(simulator) diff --git a/traitsui/testing/tests/test_ui_tester.py b/traitsui/testing/tests/test_ui_tester.py index 55f597da7..33bb3fbba 100644 --- a/traitsui/testing/tests/test_ui_tester.py +++ b/traitsui/testing/tests/test_ui_tester.py @@ -31,6 +31,7 @@ def default_traits_view(self): editor=EnumEditor( values=["employed", "unemployed"], evaluate=True, + auto_set=False, ) ), ) diff --git a/traitsui/testing/ui_tester.py b/traitsui/testing/ui_tester.py index aea4eb222..3fe0f9ab2 100644 --- a/traitsui/testing/ui_tester.py +++ b/traitsui/testing/ui_tester.py @@ -250,11 +250,13 @@ def _ensure_started(self): def _set_editor_value(self, ui, name, setter): self._ensure_started() with store_exceptions_on_all_threads(): - set_editor_value(ui, name, setter, registry=self.registry) + set_editor_value( + ui, name, setter, self.gui, registry=self.registry) self.gui.process_events() def _get_editor_value(self, ui, name, getter): self._ensure_started() with store_exceptions_on_all_threads(): self.gui.process_events() - return get_editor_value(ui, name, getter, registry=self.registry) + return get_editor_value( + ui, name, getter, self.gui, registry=self.registry) diff --git a/traitsui/wx/enum_editor.py b/traitsui/wx/enum_editor.py index bb7894d62..de10f39b7 100644 --- a/traitsui/wx/enum_editor.py +++ b/traitsui/wx/enum_editor.py @@ -29,6 +29,8 @@ # traitsui.editors.drop_editor file. from traitsui.editors.enum_editor import ToolkitEditorFactory +from traitsui.testing.api import BaseSimulator, Disabled, simulate + from .editor import Editor from .constants import OKColor, ErrorColor @@ -324,6 +326,48 @@ def rebuild_editor(self): self.update_editor() +@simulate(SimpleEditor) +class SimpleEnumEditorSimulator(BaseSimulator): + + def get_text(self): + control = self.editor.control + if self.editor.factory.evaluate is None: + return control.GetString(control.GetSelection()) + else: + return control.GetValue() + + def set_text(self, text, confirmed=True): + factory = self.editor.factory + if factory.evaluate is None: + raise Disabled("Cannot set text when evaluate is None.") + + control = self.editor.control + + if confirmed: + event_type = wx.EVT_TEXT_ENTER.typeId + control.SetValue(text) + else: + event_type = wx.EVT_TEXT.typeId + event = wx.CommandEvent(event_type, control.GetId()) + event.SetString(text) + wx.PostEvent(control.GetParent(), event) + + def click_index(self, index): + control = self.editor.control + control.SetSelection(index) + + # SetSelection does not emit events. + if self.editor.factory.evaluate is None: + event_type = wx.EVT_CHOICE.typeId + else: + event_type = wx.EVT_COMBOBOX.typeId + event = wx.CommandEvent(event_type, control.GetId()) + text = control.GetString(index) + event.SetString(text) + event.SetInt(index) + wx.PostEvent(control.GetParent(), event) + + class RadioEditor(BaseEditor): """ Enumeration editor, used for the "custom" style, that displays radio buttons. diff --git a/traitsui/wx/instance_editor.py b/traitsui/wx/instance_editor.py index 282636a14..dc5f5c8b2 100644 --- a/traitsui/wx/instance_editor.py +++ b/traitsui/wx/instance_editor.py @@ -19,6 +19,7 @@ toolkit. """ +import contextlib import wx @@ -33,6 +34,7 @@ from traitsui.helper import user_name_for from traitsui.handler import Handler from traitsui.instance_choice import InstanceChoiceItem +from traitsui.testing.api import BaseSimulator, simulate from . import toolkit from .editor import Editor @@ -479,6 +481,20 @@ def _view_changed(self, view): self.resynch_editor() +@simulate(CustomEditor) +class CustomInstanceEditorSimulator(BaseSimulator): + + @contextlib.contextmanager + def get_ui(self): + self.editor.resynch_editor() + ui = self.editor._ui + try: + yield ui + finally: + ui.dispose() + self.editor._ui = None + + class SimpleEditor(CustomEditor): """ Simple style of editor for instances, which displays a button. Clicking the button displays a dialog box in which the instance can be edited. @@ -509,13 +525,7 @@ def edit_instance(self, event): button. """ # Create the user interface: - factory = self.factory - view = self.ui.handler.trait_view_for( - self.ui.info, factory.view, self.value, self.object_name, self.name - ) - ui = self.value.edit_traits( - view, self.control, factory.kind, id=factory.id - ) + ui = self._create_ui() # Check to see if the view was 'modal', in which case it will already # have been closed (i.e. is None) by the time we get control back: @@ -539,3 +549,30 @@ def resynch_editor(self): label = user_name_for(self.name) button.SetLabel(label) button.Enable(isinstance(self.value, HasTraits)) + + def _create_ui(self): + """ Create the user interface for editing the instance. + + Returns + ------- + ui: UI + """ + factory = self.factory + view = self.ui.handler.trait_view_for( + self.ui.info, factory.view, self.value, self.object_name, self.name + ) + return self.value.edit_traits( + view, self.control, factory.kind, id=factory.id + ) + + +@simulate(SimpleEditor) +class SimpleInstanceEditorSimulator(BaseSimulator): + + @contextlib.contextmanager + def get_ui(self): + ui = self.editor._create_ui() + try: + yield ui + finally: + ui.dispose() diff --git a/traitsui/wx/text_editor.py b/traitsui/wx/text_editor.py index 360f55939..00687e3a8 100644 --- a/traitsui/wx/text_editor.py +++ b/traitsui/wx/text_editor.py @@ -34,6 +34,8 @@ from .constants import OKColor +from traitsui.testing.api import BaseSimulator, Disabled, simulate + # Readonly text editor with view state colors: HoverColor = wx.LIGHT_GREY @@ -73,8 +75,7 @@ def init(self, parent): style |= wx.TE_PASSWORD multi_line = (style & wx.TE_MULTILINE) != 0 - if multi_line: - self.scrollable = True + self.scrollable = multi_line if factory.enter_set and (not multi_line): control = wx.TextCtrl( @@ -95,6 +96,19 @@ def init(self, parent): self.set_error_state(False) self.set_tooltip() + def dispose(self): + factory = self.factory + control = self.control + parent = control.GetParent() + + if factory.enter_set and (not self.scrollable): + parent.Unbind(wx.EVT_TEXT_ENTER, id=control.GetId()) + + control.Unbind(wx.EVT_KILL_FOCUS) + if factory.auto_set: + parent.Unbind(wx.EVT_TEXT, id=control.GetId()) + super().dispose() + def update_object(self, event): """ Handles the user entering input data in the edit control. """ @@ -177,6 +191,19 @@ class CustomEditor(SimpleEditor): base_style = wx.TE_MULTILINE +@simulate(CustomEditor) +@simulate(SimpleEditor) +class TextEditorSimulator(BaseSimulator): + + def set_text(self, text, confirmed=True): + if not self.editor.control.IsEnabled(): + raise Disabled("Text field is disabled.") + self.editor.control.SetValue(text) + + def get_text(self): + return self.editor.control.GetValue() + + class ReadonlyEditor(BaseReadonlyEditor): """ Read-only style of text editor, which displays a read-only text field. """ From 73d4859d06952c1f21ebfd0c35eb85d8fe205932 Mon Sep 17 00:00:00 2001 From: Kit Yan Choi Date: Mon, 1 Jun 2020 17:45:23 +0100 Subject: [PATCH 08/16] Revert unrelated change accidentally get mixed in --- traitsui/wx/text_editor.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/traitsui/wx/text_editor.py b/traitsui/wx/text_editor.py index 00687e3a8..5c922f146 100644 --- a/traitsui/wx/text_editor.py +++ b/traitsui/wx/text_editor.py @@ -75,7 +75,8 @@ def init(self, parent): style |= wx.TE_PASSWORD multi_line = (style & wx.TE_MULTILINE) != 0 - self.scrollable = multi_line + if multi_line: + self.scrollable = True if factory.enter_set and (not multi_line): control = wx.TextCtrl( @@ -96,19 +97,6 @@ def init(self, parent): self.set_error_state(False) self.set_tooltip() - def dispose(self): - factory = self.factory - control = self.control - parent = control.GetParent() - - if factory.enter_set and (not self.scrollable): - parent.Unbind(wx.EVT_TEXT_ENTER, id=control.GetId()) - - control.Unbind(wx.EVT_KILL_FOCUS) - if factory.auto_set: - parent.Unbind(wx.EVT_TEXT, id=control.GetId()) - super().dispose() - def update_object(self, event): """ Handles the user entering input data in the edit control. """ From f25b4d2032467e5875e6abf1ce65dd99b3fb8d7a Mon Sep 17 00:00:00 2001 From: Kit Yan Choi Date: Mon, 1 Jun 2020 18:04:43 +0100 Subject: [PATCH 09/16] Improve docstrings --- traitsui/testing/simulation.py | 11 ++++++----- traitsui/testing/ui_tester.py | 8 +++++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/traitsui/testing/simulation.py b/traitsui/testing/simulation.py index fcff68e1f..d6aff96a6 100644 --- a/traitsui/testing/simulation.py +++ b/traitsui/testing/simulation.py @@ -3,12 +3,13 @@ class BaseSimulator: - """ The base class for all simulators to be used for simulating user - interactions with GUI components for testing TraitsUI and applications - written using TraitsUI. + """ The base class whose subclasses are responsible simulating user + interactions with a specific GUI component. This is typically used for + testing GUI applications written using TraitsUI. - Each simulator should be associated with one or many specific Editor of - TraitsUI. + Each simulator subclass can be associated with one or many toolkit specific + subclasses of Editor. Each instance of a BaseSimulator should be associated + with a single instance of Editor in a UI. Concrete implementations should aim at programmatically triggering UI events by manipulating UI components, e.g. clicking a button, instead of diff --git a/traitsui/testing/ui_tester.py b/traitsui/testing/ui_tester.py index 3fe0f9ab2..6ddcbd665 100644 --- a/traitsui/testing/ui_tester.py +++ b/traitsui/testing/ui_tester.py @@ -14,9 +14,11 @@ class UITester: An instance of UITester can be instantiated inside a test and then be used to drive changes on a Traits application via GUI components, imitating - user interactions. + user interactions. Inspection methods are also defined. - Several inspection methods are also supported. + Note that for a given GUI component, not all types of user interactions are + possible. The corresponding methods are likely not implemented in that + sitations. ``UITester`` can be used as a context manager. Alternatively its ``start`` and ``stop`` methods can be used in a test's set up and tear down code. @@ -86,7 +88,7 @@ def get_text(self, ui, name): by a name (or extended name). This method may not be implemented by editors that do not support - text editing. + the representation of text. Parameters ---------- From cf82e4f802caa7cd9422acb62628e61411c8fc62 Mon Sep 17 00:00:00 2001 From: Kit Yan Choi Date: Wed, 3 Jun 2020 08:41:01 +0100 Subject: [PATCH 10/16] Allow multiple registries --- traitsui/qt4/enum_editor.py | 3 +- traitsui/qt4/instance_editor.py | 5 +- traitsui/qt4/text_editor.py | 5 +- traitsui/testing/api.py | 2 + traitsui/testing/simulation.py | 87 ++++++++++++++++++++----------- traitsui/testing/ui_tester.py | 90 +++++++++++++++++++++++++-------- traitsui/wx/enum_editor.py | 3 +- traitsui/wx/instance_editor.py | 5 +- traitsui/wx/text_editor.py | 5 +- 9 files changed, 144 insertions(+), 61 deletions(-) diff --git a/traitsui/qt4/enum_editor.py b/traitsui/qt4/enum_editor.py index b0ccd2f0d..eb22a39a4 100644 --- a/traitsui/qt4/enum_editor.py +++ b/traitsui/qt4/enum_editor.py @@ -31,6 +31,7 @@ from .editor import Editor from traitsui.testing.api import BaseSimulator, Disabled, simulate +from traitsui.testing.simulation import DEFAULT_REGISTRY # default formatting function (would import from string, but not in Python 3) capitalize = lambda s: s.capitalize() @@ -300,7 +301,7 @@ def update_autoset_text_object(self): return self.update_text_object(text) -@simulate(SimpleEditor) +@simulate(SimpleEditor, registry=DEFAULT_REGISTRY) class SimpleEnumEditorSimulator(BaseSimulator): """ A simulator for testing GUI components with the simple EnumEditor. diff --git a/traitsui/qt4/instance_editor.py b/traitsui/qt4/instance_editor.py index eaa436707..33fc5048f 100644 --- a/traitsui/qt4/instance_editor.py +++ b/traitsui/qt4/instance_editor.py @@ -34,6 +34,7 @@ from .helper import position_window from traitsui.testing.api import BaseSimulator, simulate +from traitsui.testing.simulation import DEFAULT_REGISTRY OrientationMap = { @@ -399,7 +400,7 @@ def _view_changed(self, view): self.resynch_editor() -@simulate(CustomEditor) +@simulate(CustomEditor, registry=DEFAULT_REGISTRY) class CustomInstanceEditorSimulator(BaseSimulator): """ A simulator for custom instance editor: it delegates commands and queries to the internal UI panel. @@ -479,7 +480,7 @@ def _parent_closed(self): self._dialog_ui = None -@simulate(SimpleEditor) +@simulate(SimpleEditor, registry=DEFAULT_REGISTRY) class SimpleInstanceEditorSimulator(BaseSimulator): """ A simulator for simple instance editor. It launches the dialog and delegates commands and queries to the dialog UI. diff --git a/traitsui/qt4/text_editor.py b/traitsui/qt4/text_editor.py index 89d465e82..2f7805a1f 100644 --- a/traitsui/qt4/text_editor.py +++ b/traitsui/qt4/text_editor.py @@ -30,6 +30,7 @@ from .constants import OKColor from traitsui.testing.api import BaseSimulator, Disabled, simulate +from traitsui.testing.simulation import DEFAULT_REGISTRY class SimpleEditor(Editor): @@ -183,8 +184,8 @@ class CustomEditor(SimpleEditor): base_style = QtGui.QTextEdit -@simulate(CustomEditor) -@simulate(SimpleEditor) +@simulate(CustomEditor, registry=DEFAULT_REGISTRY) +@simulate(SimpleEditor, registry=DEFAULT_REGISTRY) class TextEditorSimulator(BaseSimulator): """ A simulator for testing GUI components with the simple and custom styled TextEditor. diff --git a/traitsui/testing/api.py b/traitsui/testing/api.py index baaf8fb0e..5c3f2ddf3 100644 --- a/traitsui/testing/api.py +++ b/traitsui/testing/api.py @@ -2,5 +2,7 @@ from traitsui.testing.exceptions import Disabled # noqa: F401 from traitsui.testing.simulation import ( BaseSimulator, + DEFAULT_REGISTRY, simulate, + SimulatorRegistry, ) diff --git a/traitsui/testing/simulation.py b/traitsui/testing/simulation.py index d6aff96a6..65c55f680 100644 --- a/traitsui/testing/simulation.py +++ b/traitsui/testing/simulation.py @@ -1,6 +1,8 @@ import contextlib +_TRAITSUI, _ = __name__.split(".", 1) + class BaseSimulator: """ The base class whose subclasses are responsible simulating user @@ -252,22 +254,28 @@ def get_simulator_class(self, editor_class): ) from None -#: Default registry for providing default simulators. -REGISTRY = SimulatorRegistry() +#: Registry for providing traitsui default simulators. +DEFAULT_REGISTRY = SimulatorRegistry() -def simulate(editor_class, registry=REGISTRY): +def simulate(editor_class, registry): """ Decorator for registering a subclass of BaseSimulator for simulating a particular subclass of Editor. + When this decorator is used outside of TraitsUI, it is highly recommended + that a separate registry is used instead of TraitsUI's default registry. + This will prevent conflicts with default simulators being contributed by + TraitsUI now or in the future. + + See ``UITester`` for supplying a list of registries to try in the order + of priority. + Parameters ---------- editor_class : subclass of traitsui.editor.Editor The Editor class to simulate. - registry : SimulatorRegistry, optional - Registry to used. Default is to use a global registry provided - by TraitsUI. - To undo a registration, see ``SimulatorRegistry.unregister``. + registry : SimulatorRegistry + Registry to be used for mapping the editor to the simulator. """ def wrapper(simulator_class): registry.register(editor_class, simulator_class) @@ -275,7 +283,7 @@ def wrapper(simulator_class): return wrapper -def set_editor_value(ui, name, setter, gui, registry=REGISTRY): +def set_editor_value(ui, name, setter, gui, registries): """ Perform actions to modify GUI components. Parameters @@ -289,11 +297,13 @@ def set_editor_value(ui, name, setter, gui, registry=REGISTRY): Callable to perform simulation. gui : pyface.gui.GUI Object for driving the GUI event loop. - registry : SimulatorRegistry, optional - The registry from which to find a BaseSimulator for the retrieved - editor. + registries : list of SimulatorRegistry + The registries from which to find a BaseSimulator for the retrieved + editor. The first registry that returns a simulator will stop other + registries from being used. """ - simulator, name = _get_one_simulator(ui=ui, name=name, registry=registry) + simulator, name = _get_one_simulator( + ui=ui, name=name, registries=registries) with simulator.get_ui() as alternative_ui: if alternative_ui is not NotImplemented: set_editor_value( @@ -301,14 +311,14 @@ def set_editor_value(ui, name, setter, gui, registry=REGISTRY): name=name, setter=setter, gui=gui, - registry=registry, + registries=registries, ) else: setter(simulator) gui.process_events() -def get_editor_value(ui, name, getter, gui, registry=REGISTRY): +def get_editor_value(ui, name, getter, gui, registries): """ Perform a query on GUI components for inspection purposes. Parameters @@ -322,16 +332,18 @@ def get_editor_value(ui, name, getter, gui, registry=REGISTRY): Callable to retrieve value or values from the GUI. gui : pyface.gui.GUI Object for driving the GUI event loop. - registry : SimulatorRegistry, optional - The registry from which to find a BaseSimulator for the retrieved - editor. + registries : list of SimulatorRegistry + The registries from which to find a BaseSimulator for the retrieved + editor. The first registry that returns a simulator will stop other + registries from being used. Returns ------- value : any Any value returned by the getter. """ - simulator, name = _get_one_simulator(ui=ui, name=name, registry=registry) + simulator, name = _get_one_simulator( + ui=ui, name=name, registries=registries) with simulator.get_ui() as alternative_ui: gui.process_events() if alternative_ui is not NotImplemented: @@ -340,12 +352,12 @@ def get_editor_value(ui, name, getter, gui, registry=REGISTRY): name=name, getter=getter, gui=gui, - registry=registry, + registries=registries, ) return getter(simulator) -def _get_one_simulator(ui, name, registry=REGISTRY): +def _get_one_simulator(ui, name, registries): """ Return one instance of BaseSimulation for an editor uniquely identified from the given UI and name. @@ -356,9 +368,9 @@ def _get_one_simulator(ui, name, registry=REGISTRY): name : str A single or an extended name for retreiving an editor on a UI. e.g. "attr", "model.attr1.attr2" - registry : SimulatorRegistry, optional - The registry from which to find a BaseSimulator for the retrieved - editor. + registries : list of SimulatorRegistry + The registries from which to find a BaseSimulator for the retrieved + editor, in a descending order of priority. Returns ------- @@ -372,7 +384,8 @@ def _get_one_simulator(ui, name, registry=REGISTRY): ValuError If zero or more than one editors are found. """ - simulators, new_name = _get_simulators(ui=ui, name=name, registry=registry) + simulators, new_name = _get_simulators( + ui=ui, name=name, registries=registries) if not simulators: raise ValueError( @@ -385,7 +398,7 @@ def _get_one_simulator(ui, name, registry=REGISTRY): return simulator, new_name -def _get_simulators(ui, name, registry=REGISTRY): +def _get_simulators(ui, name, registries): """ Return instances of BaseSimulator from an instance of traitsui.ui.UI with a given extended name. @@ -393,6 +406,10 @@ def _get_simulators(ui, name, registry=REGISTRY): ---------- ui : traitsui.ui.UI name : str + registries : list of SimulatorRegistry + The registries from which to find a BaseSimulator for the retrieved + editor. The first registry that returns a simulator will stop other + registries from being used. """ if "." in name: editor_name, name = name.split(".", 1) @@ -400,8 +417,20 @@ def _get_simulators(ui, name, registry=REGISTRY): editor_name = name editors = ui.get_editors(editor_name) - simulators = [ - registry.get_simulator_class(editor.__class__)(editor) - for editor in editors - ] + simulators = [] + for editor in editors: + editor_class = editor.__class__ + for registry in registries: + try: + simulator_class = registry.get_simulator_class(editor_class) + except KeyError: + continue + else: + break + else: + raise KeyError( + "No simulators can be found for {!r}".format(editor_class) + ) + simulators.append(simulator_class(editor)) + return simulators, name diff --git a/traitsui/testing/ui_tester.py b/traitsui/testing/ui_tester.py index 6ddcbd665..55ccbf7ec 100644 --- a/traitsui/testing/ui_tester.py +++ b/traitsui/testing/ui_tester.py @@ -4,7 +4,7 @@ from pyface.gui import GUI from traitsui.testing.simulation import ( - get_editor_value, set_editor_value, REGISTRY + get_editor_value, set_editor_value, DEFAULT_REGISTRY, ) from traitsui.tests._tools import store_exceptions_on_all_threads @@ -24,17 +24,22 @@ class UITester: and ``stop`` methods can be used in a test's set up and tear down code. """ - def __init__(self, registry=REGISTRY): + def __init__(self, registries=None): """ Initialize a tester for testing GUI and traits interaction. Parameters ---------- - registry : SimulatorRegistry, optional - A registry of simulators for different editors. - Default is a registry provided by TraitsUI. + registries : list of SimulatorRegistry, optional + Registries of simulators for different editors, in the order + of decreasing priority. Default is a list containing TraitsUI's + registry only. """ self.gui = None - self.registry = registry + + if registries is None: + self.registries = [DEFAULT_REGISTRY] + else: + self.registries = registries def start(self): """ Start GUI testing. @@ -102,7 +107,7 @@ def get_text(self, ui, name): ------- text : str """ - return self._get_editor_value( + return self.get_editor_value( ui=ui, name=name, getter=lambda s: s.get_text() ) @@ -129,7 +134,7 @@ def set_text(self, ui, name, text, confirmed=True): configured such that no events are fired until the user confirms the change. """ - self._set_editor_value( + self.set_editor_value( ui=ui, name=name, setter=lambda s: s.set_text(text, confirmed=confirmed) @@ -154,7 +159,7 @@ def get_date(self, ui, name): ------- date : datetime.date """ - return self._get_editor_value( + return self.get_editor_value( ui=ui, name=name, getter=lambda s: s.get_date() ) @@ -175,7 +180,7 @@ def set_date(self, ui, name, date): date : datetime.date The date to be set. """ - self._set_editor_value( + self.set_editor_value( ui=ui, name=name, setter=lambda s: s.set_date(date) ) @@ -196,7 +201,7 @@ def click_date(self, ui, name, date): date : datetime.date The date to be set. """ - self._set_editor_value( + self.set_editor_value( ui=ui, name=name, setter=lambda s: s.click_date(date) ) @@ -215,7 +220,7 @@ def click(self, ui, name): A single or an extended name for retreiving an editor on a UI. e.g. "attr", "model.attr1.attr2" """ - self._set_editor_value( + self.set_editor_value( ui=ui, name=name, setter=lambda s: s.click() ) @@ -238,27 +243,68 @@ def click_index(self, ui, name, index): index : int A 0-based index to indicate where the click event should occur. """ - self._set_editor_value( + self.set_editor_value( ui=ui, name=name, setter=lambda s: s.click_index(index) ) - # Private methods + def set_editor_value(self, ui, name, setter): + """ General method for setting value(s) on an editor via a simulator. - def _ensure_started(self): - if self.gui is None: - raise ValueError( - "'start' has not been called on {!r}.".format(self)) + Useful for calling a custom method on a custom simulator. - def _set_editor_value(self, ui, name, setter): + Parameters + ---------- + ui : traitsui.ui.UI + The UI created, e.g. by ``create_ui``. + name : str + A single or an extended name for retreiving an editor on a UI. + e.g. "attr", "model.attr1.attr2" + setter : callable(BaseSimulator) + Callable for simulating user interaction on the editor retrieved. + The callable will receive an instance of a BaseSimulator + created for the found editor. The simulator type refers to the + first simulator class found in the registries provided to this + tester. + """ self._ensure_started() with store_exceptions_on_all_threads(): set_editor_value( - ui, name, setter, self.gui, registry=self.registry) + ui, name, setter, self.gui, registries=self.registries) self.gui.process_events() - def _get_editor_value(self, ui, name, getter): + def get_editor_value(self, ui, name, getter): + """ General method for getting value(s) on an editor via a simulator. + + Useful for calling a custom method on a custom simulator. + + Parameters + ---------- + ui : traitsui.ui.UI + The UI created, e.g. by ``create_ui``. + name : str + A single or an extended name for retreiving an editor on a UI. + e.g. "attr", "model.attr1.attr2" + getter : callable(BaseSimulator) -> any + Callable for querying GUI component state on the editor retrieved. + The callable will receive an instance of a BaseSimulator + created for the found editor. The simulator type refers to the + first simulator class found in the registries provided to this + tester. + + Returns + ------- + value : any + Value returned by the getter. + """ self._ensure_started() with store_exceptions_on_all_threads(): self.gui.process_events() return get_editor_value( - ui, name, getter, self.gui, registry=self.registry) + ui, name, getter, self.gui, registries=self.registries) + + # Private methods + + def _ensure_started(self): + if self.gui is None: + raise ValueError( + "'start' has not been called on {!r}.".format(self)) diff --git a/traitsui/wx/enum_editor.py b/traitsui/wx/enum_editor.py index de10f39b7..c47b4136c 100644 --- a/traitsui/wx/enum_editor.py +++ b/traitsui/wx/enum_editor.py @@ -30,6 +30,7 @@ from traitsui.editors.enum_editor import ToolkitEditorFactory from traitsui.testing.api import BaseSimulator, Disabled, simulate +from traitsui.testing.simulation import DEFAULT_REGISTRY from .editor import Editor @@ -326,7 +327,7 @@ def rebuild_editor(self): self.update_editor() -@simulate(SimpleEditor) +@simulate(SimpleEditor, registry=DEFAULT_REGISTRY) class SimpleEnumEditorSimulator(BaseSimulator): def get_text(self): diff --git a/traitsui/wx/instance_editor.py b/traitsui/wx/instance_editor.py index dc5f5c8b2..390b854c0 100644 --- a/traitsui/wx/instance_editor.py +++ b/traitsui/wx/instance_editor.py @@ -35,6 +35,7 @@ from traitsui.handler import Handler from traitsui.instance_choice import InstanceChoiceItem from traitsui.testing.api import BaseSimulator, simulate +from traitsui.testing.simulation import DEFAULT_REGISTRY from . import toolkit from .editor import Editor @@ -481,7 +482,7 @@ def _view_changed(self, view): self.resynch_editor() -@simulate(CustomEditor) +@simulate(CustomEditor, registry=DEFAULT_REGISTRY) class CustomInstanceEditorSimulator(BaseSimulator): @contextlib.contextmanager @@ -566,7 +567,7 @@ def _create_ui(self): ) -@simulate(SimpleEditor) +@simulate(SimpleEditor, registry=DEFAULT_REGISTRY) class SimpleInstanceEditorSimulator(BaseSimulator): @contextlib.contextmanager diff --git a/traitsui/wx/text_editor.py b/traitsui/wx/text_editor.py index 5c922f146..f6388ce70 100644 --- a/traitsui/wx/text_editor.py +++ b/traitsui/wx/text_editor.py @@ -35,6 +35,7 @@ from .constants import OKColor from traitsui.testing.api import BaseSimulator, Disabled, simulate +from traitsui.testing.simulation import DEFAULT_REGISTRY # Readonly text editor with view state colors: @@ -179,8 +180,8 @@ class CustomEditor(SimpleEditor): base_style = wx.TE_MULTILINE -@simulate(CustomEditor) -@simulate(SimpleEditor) +@simulate(CustomEditor, registry=DEFAULT_REGISTRY) +@simulate(SimpleEditor, registry=DEFAULT_REGISTRY) class TextEditorSimulator(BaseSimulator): def set_text(self, text, confirmed=True): From b3754eeda95b13528250e8a17cd00d886151e09a Mon Sep 17 00:00:00 2001 From: Kit Yan Choi Date: Wed, 3 Jun 2020 13:14:21 +0100 Subject: [PATCH 11/16] Make it easy to contribute alternative simulator implementations --- traitsui/testing/simulation.py | 200 +++++++++++++++++++-------------- traitsui/testing/ui_tester.py | 22 +++- 2 files changed, 134 insertions(+), 88 deletions(-) diff --git a/traitsui/testing/simulation.py b/traitsui/testing/simulation.py index 65c55f680..9e8d29e1f 100644 --- a/traitsui/testing/simulation.py +++ b/traitsui/testing/simulation.py @@ -302,20 +302,40 @@ def set_editor_value(ui, name, setter, gui, registries): editor. The first registry that returns a simulator will stop other registries from being used. """ - simulator, name = _get_one_simulator( - ui=ui, name=name, registries=registries) - with simulator.get_ui() as alternative_ui: - if alternative_ui is not NotImplemented: - set_editor_value( - ui=alternative_ui, - name=name, - setter=setter, - gui=gui, - registries=registries, - ) - else: - setter(simulator) - gui.process_events() + editor, name = _get_editor(ui, name) + editor_class = editor.__class__ + + exceptions = [] + + for simulator_class in _iter_simulator_classes(registries, editor_class): + simulator = simulator_class(editor) + with simulator.get_ui() as alternative_ui: + try: + if alternative_ui is not NotImplemented: + set_editor_value( + ui=alternative_ui, + name=name, + setter=setter, + gui=gui, + registries=registries, + ) + else: + setter(simulator) + + except NotImplementedError as e: + exceptions.append(e) + continue + else: + gui.process_events() + return + + raise NotImplementedError( + "No implementation found for simulating {!r}. " + "These simulators are tried:\n{}".format( + editor, + "\n".join(str(exception) for exception in exceptions) + ) + ) def get_editor_value(ui, name, getter, gui, registries): @@ -342,95 +362,111 @@ def get_editor_value(ui, name, getter, gui, registries): value : any Any value returned by the getter. """ - simulator, name = _get_one_simulator( - ui=ui, name=name, registries=registries) - with simulator.get_ui() as alternative_ui: - gui.process_events() - if alternative_ui is not NotImplemented: - return get_editor_value( - ui=alternative_ui, - name=name, - getter=getter, - gui=gui, - registries=registries, - ) - return getter(simulator) + editor, name = _get_editor(ui, name) + editor_class = editor.__class__ + exceptions = [] + + for simulator_class in _iter_simulator_classes(registries, editor_class): + simulator = simulator_class(editor) + with simulator.get_ui() as alternative_ui: + gui.process_events() + try: + if alternative_ui is not NotImplemented: + return get_editor_value( + ui=alternative_ui, + name=name, + getter=getter, + gui=gui, + registries=registries, + ) + else: + return getter(simulator) + + except NotImplementedError as e: + exceptions.append(e) + continue + + raise NotImplementedError( + "No implementation found for simulating {!r}. " + "These simulators are tried:\n{}".format( + editor, + "\n".join(str(exception) for exception in exceptions) + ) + ) -def _get_one_simulator(ui, name, registries): - """ Return one instance of BaseSimulation for an editor uniquely identified - from the given UI and name. + +def _iter_simulator_classes(registries, editor_class): + """ For a given list of SimulatorRegistry, yield all the simulator classes + for the given Editor class. Parameters ---------- - ui : traitsui.ui.UI - The UI from which an editor will be retrieved. - name : str - A single or an extended name for retreiving an editor on a UI. - e.g. "attr", "model.attr1.attr2" registries : list of SimulatorRegistry - The registries from which to find a BaseSimulator for the retrieved - editor, in a descending order of priority. + List of registries to obtain simulators from. + editor_class : traitsui.ui.editor.Editor + The editor class to obtain simulators for. - Returns - ------- - simulator : BaseSimulator - Simulator for the editor found. - name : str - Modified name if the original name is an extended name. - - Raises + Yields ------ - ValuError - If zero or more than one editors are found. + simulator_class : subclass of BaseSimulator """ - simulators, new_name = _get_simulators( - ui=ui, name=name, registries=registries) - - if not simulators: - raise ValueError( - "No editors can be found with name {!r}".format(name) - ) - if len(simulators) > 1: - raise ValueError("Found multiple editors with name {!r}.".format(name)) - - simulator, = simulators - return simulator, new_name + for registry in registries: + try: + yield registry.get_simulator_class(editor_class) + except KeyError: + continue -def _get_simulators(ui, name, registries): - """ Return instances of BaseSimulator from an instance of traitsui.ui.UI +def _get_editors(ui, name): + """ Return a list of Editor from an instance of traitsui.ui.UI with a given extended name. Parameters ---------- ui : traitsui.ui.UI + The UI from which an editor will be retrieved. name : str - registries : list of SimulatorRegistry - The registries from which to find a BaseSimulator for the retrieved - editor. The first registry that returns a simulator will stop other - registries from being used. + A single or an extended name for retreiving an editor on a UI. + e.g. "attr", "model.attr1.attr2" + + Returns + ------- + editors : list of Editor + The editors found. The list may be empty. """ if "." in name: editor_name, name = name.split(".", 1) else: editor_name = name - editors = ui.get_editors(editor_name) + return ui.get_editors(editor_name), name - simulators = [] - for editor in editors: - editor_class = editor.__class__ - for registry in registries: - try: - simulator_class = registry.get_simulator_class(editor_class) - except KeyError: - continue - else: - break - else: - raise KeyError( - "No simulators can be found for {!r}".format(editor_class) - ) - simulators.append(simulator_class(editor)) - return simulators, name +def _get_editor(ui, name): + """ Return a single Editor from an instance of traitsui.ui.UI with + a given extended name. Raise if zero or many editors are found. + + Parameters + ---------- + ui : traitsui.ui.UI + The UI from which an editor will be retrieved. + name : str + A single or an extended name for retreiving an editor on a UI. + e.g. "attr", "model.attr1.attr2" + + Returns + ------- + editor : Editor + The single editor found. + name : str + Modified name if the original name is an extended name. + """ + editors, new_name = _get_editors(ui, name) + if not editors: + raise ValueError( + "No editors can be found with name {!r}".format(name) + ) + if len(editors) > 1: + raise ValueError("Found multiple editors with name {!r}.".format(name)) + editor, = editors + return editor, new_name diff --git a/traitsui/testing/ui_tester.py b/traitsui/testing/ui_tester.py index 55ccbf7ec..51648da20 100644 --- a/traitsui/testing/ui_tester.py +++ b/traitsui/testing/ui_tester.py @@ -31,15 +31,15 @@ def __init__(self, registries=None): ---------- registries : list of SimulatorRegistry, optional Registries of simulators for different editors, in the order - of decreasing priority. Default is a list containing TraitsUI's - registry only. + of decreasing priority. A shallow copy will be made. + Default is a list containing TraitsUI's registry only. """ self.gui = None if registries is None: - self.registries = [DEFAULT_REGISTRY] + self._registries = [DEFAULT_REGISTRY] else: - self.registries = registries + self._registries = registries.copy() def start(self): """ Start GUI testing. @@ -62,6 +62,16 @@ def __enter__(self): def __exit__(self, *args, **kwargs): self.stop() + def add_registry(self, registry): + """ Add a SimulatorRegistry to the top of the registry list, i.e. + registry with the highest priority. + + Parameters + ---------- + registry : SimulatorRegistry + """ + self._registries.insert(0, registry) + @contextmanager def create_ui(self, object, ui_kwargs=None): """ Context manager to create a UI and dispose it upon exit. @@ -269,7 +279,7 @@ def set_editor_value(self, ui, name, setter): self._ensure_started() with store_exceptions_on_all_threads(): set_editor_value( - ui, name, setter, self.gui, registries=self.registries) + ui, name, setter, self.gui, registries=self._registries) self.gui.process_events() def get_editor_value(self, ui, name, getter): @@ -300,7 +310,7 @@ def get_editor_value(self, ui, name, getter): with store_exceptions_on_all_threads(): self.gui.process_events() return get_editor_value( - ui, name, getter, self.gui, registries=self.registries) + ui, name, getter, self.gui, registries=self._registries) # Private methods From 1f9c81ec84a6e7f1d6ef32d9c28085fa27baa7f3 Mon Sep 17 00:00:00 2001 From: Kit Yan Choi Date: Wed, 3 Jun 2020 13:45:07 +0100 Subject: [PATCH 12/16] Add test to demonstrate extending simulator --- traitsui/testing/tests/test_ui_tester.py | 58 +++++++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/traitsui/testing/tests/test_ui_tester.py b/traitsui/testing/tests/test_ui_tester.py index 33bb3fbba..119562927 100644 --- a/traitsui/testing/tests/test_ui_tester.py +++ b/traitsui/testing/tests/test_ui_tester.py @@ -5,9 +5,13 @@ Bool, Button, Date, Enum, Instance, HasTraits, List, Str, Int, on_trait_change, Property ) -from traitsui.api import EnumEditor, Item, ModelView, View -from traitsui.testing.api import Disabled, UITester +from traitsui.api import EnumEditor, Item, ModelView, TextEditor, View +from traitsui.testing.api import ( + BaseSimulator, Disabled, simulate, SimulatorRegistry, UITester, +) from traitsui.tests._tools import ( + is_current_backend_qt4, + is_current_backend_wx, requires_one_of, QT, WX, @@ -118,3 +122,53 @@ def test_instance_text_editor_query(self): app.model.father.employment_status = "unemployed" actual = self.tester.get_text(ui, "father.employment_status") self.assertEqual(actual, "unemployed") + + +# Test contributing custom simulator methods +LOCAL_REGISTRY = SimulatorRegistry() + +if is_current_backend_qt4(): + + from traitsui.qt4.text_editor import SimpleEditor as QtTextEditor + + @simulate(QtTextEditor, LOCAL_REGISTRY) + class QtCustomSimulator(BaseSimulator): + + def get_placeholder_text(self): + return self.control.placeholderText() + + +if is_current_backend_wx(): + + from traitsui.wx.text_editor import SimpleEditor as WxTextEditor + + @simulate(WxTextEditor, LOCAL_REGISTRY) + class WxCustomSimulator(BaseSimulator): + + def get_placeholder_text(self): + return self.editor.control.GetHint() + + +class TestUITesterSimulateExtension(unittest.TestCase): + """ Test when the existing simulators are not enough, it is easy to + contribute new ones. + """ + + def test_custom_simulator_used(self): + tester = UITester() + tester.add_registry(LOCAL_REGISTRY) + + view = View( + Item("first_name", editor=TextEditor(placeholder="Enter name")) + ) + child = Child(first_name="Paul") + with tester, tester.create_ui(child, dict(view=view)) as ui: + + actual = tester.get_editor_value( + ui, "first_name", lambda s: s.get_placeholder_text() + ) + self.assertEqual(actual, "Enter name") + + # the default simulator from TraitsUI is still accessible. + actual = tester.get_text(ui, "first_name") + self.assertEqual(actual, "Paul") From 5b3bdf9f84388949560ac0271df0c39550efe10a Mon Sep 17 00:00:00 2001 From: Kit Yan Choi Date: Wed, 3 Jun 2020 14:03:21 +0100 Subject: [PATCH 13/16] Add requires_one_of decorator --- traitsui/testing/tests/test_ui_tester.py | 1 + 1 file changed, 1 insertion(+) diff --git a/traitsui/testing/tests/test_ui_tester.py b/traitsui/testing/tests/test_ui_tester.py index 119562927..6b1498998 100644 --- a/traitsui/testing/tests/test_ui_tester.py +++ b/traitsui/testing/tests/test_ui_tester.py @@ -149,6 +149,7 @@ def get_placeholder_text(self): return self.editor.control.GetHint() +@requires_one_of([QT, WX]) class TestUITesterSimulateExtension(unittest.TestCase): """ Test when the existing simulators are not enough, it is easy to contribute new ones. From d41d08cd40c4dcf91bc43ffc3b3a02616b571d91 Mon Sep 17 00:00:00 2001 From: Kit Yan Choi Date: Wed, 3 Jun 2020 15:38:13 +0100 Subject: [PATCH 14/16] Fix test for Qt --- traitsui/testing/tests/test_ui_tester.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/traitsui/testing/tests/test_ui_tester.py b/traitsui/testing/tests/test_ui_tester.py index 6b1498998..7b82c10da 100644 --- a/traitsui/testing/tests/test_ui_tester.py +++ b/traitsui/testing/tests/test_ui_tester.py @@ -135,7 +135,7 @@ def test_instance_text_editor_query(self): class QtCustomSimulator(BaseSimulator): def get_placeholder_text(self): - return self.control.placeholderText() + return self.editor.control.placeholderText() if is_current_backend_wx(): From fe0978f6428644fbdc418d3fead7ec37aad500c2 Mon Sep 17 00:00:00 2001 From: Kit Yan Choi Date: Wed, 3 Jun 2020 17:38:39 +0100 Subject: [PATCH 15/16] placeholder is a bad example; use clicking a combobox with an index --- traitsui/testing/tests/test_ui_tester.py | 49 ++++++++++++++---------- traitsui/wx/enum_editor.py | 15 -------- 2 files changed, 28 insertions(+), 36 deletions(-) diff --git a/traitsui/testing/tests/test_ui_tester.py b/traitsui/testing/tests/test_ui_tester.py index 7b82c10da..cc41a833e 100644 --- a/traitsui/testing/tests/test_ui_tester.py +++ b/traitsui/testing/tests/test_ui_tester.py @@ -97,9 +97,6 @@ def test_instance_text_editor_from_view(self): self.tester.set_text(ui, "father.employment_status", "unemployed") self.assertEqual(app.model.father.employment_status, "unemployed") - self.tester.click_index(ui, "mother.employment_status", 1) - self.assertEqual(app.model.mother.employment_status, "unemployed") - def test_instance_text_editor_query(self): # Test popagating queries to instance simple/custom editors father = Parent(first_name="M", last_name="C") @@ -129,24 +126,36 @@ def test_instance_text_editor_query(self): if is_current_backend_qt4(): - from traitsui.qt4.text_editor import SimpleEditor as QtTextEditor + from traitsui.qt4.enum_editor import SimpleEditor as QtEnumEditor - @simulate(QtTextEditor, LOCAL_REGISTRY) + @simulate(QtEnumEditor, LOCAL_REGISTRY) class QtCustomSimulator(BaseSimulator): - def get_placeholder_text(self): - return self.editor.control.placeholderText() + def click_some_index(self, index): + self.editor.control.setCurrentIndex(index) if is_current_backend_wx(): - from traitsui.wx.text_editor import SimpleEditor as WxTextEditor + from traitsui.wx.enum_editor import SimpleEditor as WxEnumEditor - @simulate(WxTextEditor, LOCAL_REGISTRY) + @simulate(WxEnumEditor, LOCAL_REGISTRY) class WxCustomSimulator(BaseSimulator): - def get_placeholder_text(self): - return self.editor.control.GetHint() + def click_some_index(self, index): + control = self.editor.control + control.SetSelection(index) + + # SetSelection does not emit events. + if self.editor.factory.evaluate is None: + event_type = wx.EVT_CHOICE.typeId + else: + event_type = wx.EVT_COMBOBOX.typeId + event = wx.CommandEvent(event_type, control.GetId()) + text = control.GetString(index) + event.SetString(text) + event.SetInt(index) + wx.PostEvent(control.GetParent(), event) @requires_one_of([QT, WX]) @@ -159,17 +168,15 @@ def test_custom_simulator_used(self): tester = UITester() tester.add_registry(LOCAL_REGISTRY) - view = View( - Item("first_name", editor=TextEditor(placeholder="Enter name")) - ) - child = Child(first_name="Paul") - with tester, tester.create_ui(child, dict(view=view)) as ui: + parent = Parent() + with tester, tester.create_ui(parent) as ui: - actual = tester.get_editor_value( - ui, "first_name", lambda s: s.get_placeholder_text() + self.assertEqual(parent.employment_status, "employed") + tester.set_editor_value( + ui, "employment_status", lambda s: s.click_some_index(1) ) - self.assertEqual(actual, "Enter name") + self.assertEqual(parent.employment_status, "unemployed") # the default simulator from TraitsUI is still accessible. - actual = tester.get_text(ui, "first_name") - self.assertEqual(actual, "Paul") + tester.set_text(ui, "employment_status", "employed") + self.assertEqual(parent.employment_status, "employed") diff --git a/traitsui/wx/enum_editor.py b/traitsui/wx/enum_editor.py index c47b4136c..acf0a0119 100644 --- a/traitsui/wx/enum_editor.py +++ b/traitsui/wx/enum_editor.py @@ -353,21 +353,6 @@ def set_text(self, text, confirmed=True): event.SetString(text) wx.PostEvent(control.GetParent(), event) - def click_index(self, index): - control = self.editor.control - control.SetSelection(index) - - # SetSelection does not emit events. - if self.editor.factory.evaluate is None: - event_type = wx.EVT_CHOICE.typeId - else: - event_type = wx.EVT_COMBOBOX.typeId - event = wx.CommandEvent(event_type, control.GetId()) - text = control.GetString(index) - event.SetString(text) - event.SetInt(index) - wx.PostEvent(control.GetParent(), event) - class RadioEditor(BaseEditor): """ Enumeration editor, used for the "custom" style, that displays radio From cfed86834892937878702434a905d932c43c9a75 Mon Sep 17 00:00:00 2001 From: Kit Yan Choi Date: Wed, 3 Jun 2020 18:13:51 +0100 Subject: [PATCH 16/16] Better imitation of selecting an index --- traitsui/testing/tests/test_ui_tester.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/traitsui/testing/tests/test_ui_tester.py b/traitsui/testing/tests/test_ui_tester.py index cc41a833e..b7cbb24ed 100644 --- a/traitsui/testing/tests/test_ui_tester.py +++ b/traitsui/testing/tests/test_ui_tester.py @@ -132,7 +132,8 @@ def test_instance_text_editor_query(self): class QtCustomSimulator(BaseSimulator): def click_some_index(self, index): - self.editor.control.setCurrentIndex(index) + text = self.editor.control.itemText(index) + self.editor.control.currentIndexChanged[str].emit(text) if is_current_backend_wx():