diff --git a/traitsui/qt4/enum_editor.py b/traitsui/qt4/enum_editor.py index d017030b9..eb22a39a4 100644 --- a/traitsui/qt4/enum_editor.py +++ b/traitsui/qt4/enum_editor.py @@ -30,6 +30,8 @@ from .constants import OKColor, ErrorColor 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() @@ -299,6 +301,29 @@ def update_autoset_text_object(self): return self.update_text_object(text) +@simulate(SimpleEditor, registry=DEFAULT_REGISTRY) +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) + + 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..33fc5048f 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,9 @@ from .constants import DropColor from .helper import position_window +from traitsui.testing.api import BaseSimulator, simulate +from traitsui.testing.simulation import DEFAULT_REGISTRY + OrientationMap = { "default": None, @@ -396,6 +400,17 @@ def _view_changed(self, view): self.resynch_editor() +@simulate(CustomEditor, registry=DEFAULT_REGISTRY) +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): + 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 +478,18 @@ def _parent_closed(self): self._dialog_ui.control.close() self._dialog_ui.dispose() self._dialog_ui = None + + +@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. + """ + + @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..2f7805a1f 100644 --- a/traitsui/qt4/text_editor.py +++ b/traitsui/qt4/text_editor.py @@ -29,6 +29,8 @@ from .constants import OKColor +from traitsui.testing.api import BaseSimulator, Disabled, simulate +from traitsui.testing.simulation import DEFAULT_REGISTRY class SimpleEditor(Editor): @@ -182,6 +184,29 @@ class CustomEditor(SimpleEditor): base_style = QtGui.QTextEdit +@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. + + See ``traitsui.testing.api``. + """ + + 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/__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..5c3f2ddf3 --- /dev/null +++ b/traitsui/testing/api.py @@ -0,0 +1,8 @@ +from traitsui.testing.ui_tester import UITester # noqa: F401 +from traitsui.testing.exceptions import Disabled # noqa: F401 +from traitsui.testing.simulation import ( + BaseSimulator, + DEFAULT_REGISTRY, + simulate, + SimulatorRegistry, +) 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..9e8d29e1f --- /dev/null +++ b/traitsui/testing/simulation.py @@ -0,0 +1,472 @@ + +import contextlib + +_TRAITSUI, _ = __name__.split(".", 1) + + +class BaseSimulator: + """ 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 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 + 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 + + @contextlib.contextmanager + def get_ui(self): + """ 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 yield + NotImplemented and perform no additional clean ups; the original UI + will be used. + + Yields + ------ + ui : traitsui.ui.UI or NotImplemeneted + """ + yield 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, confirmed=True): + """ 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. + 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 + ------ + 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 + + +#: Registry for providing traitsui default simulators. +DEFAULT_REGISTRY = SimulatorRegistry() + + +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 + Registry to be used for mapping the editor to the simulator. + """ + def wrapper(simulator_class): + registry.register(editor_class, simulator_class) + return simulator_class + return wrapper + + +def set_editor_value(ui, name, setter, gui, registries): + """ 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. + gui : pyface.gui.GUI + Object for driving the GUI event loop. + 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. + """ + 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): + """ 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. + gui : pyface.gui.GUI + Object for driving the GUI event loop. + 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. + """ + 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 _iter_simulator_classes(registries, editor_class): + """ For a given list of SimulatorRegistry, yield all the simulator classes + for the given Editor class. + + Parameters + ---------- + registries : list of SimulatorRegistry + List of registries to obtain simulators from. + editor_class : traitsui.ui.editor.Editor + The editor class to obtain simulators for. + + Yields + ------ + simulator_class : subclass of BaseSimulator + """ + for registry in registries: + try: + yield registry.get_simulator_class(editor_class) + except KeyError: + continue + + +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 + 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 + return ui.get_editors(editor_name), 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/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..b7cbb24ed --- /dev/null +++ b/traitsui/testing/tests/test_ui_tester.py @@ -0,0 +1,183 @@ +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 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, +) + + +class Parent(HasTraits): + + first_name = Str() + last_name = Str() + 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, + auto_set=False, + ) + ), + ) + + +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) + view = View( + Item("model.father", style="simple"), + Item("model.mother", style="custom"), + ) + app = SimpleApplication(model=child) + 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") + + 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") + + 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) + 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") + + 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") + + +# Test contributing custom simulator methods +LOCAL_REGISTRY = SimulatorRegistry() + +if is_current_backend_qt4(): + + from traitsui.qt4.enum_editor import SimpleEditor as QtEnumEditor + + @simulate(QtEnumEditor, LOCAL_REGISTRY) + class QtCustomSimulator(BaseSimulator): + + def click_some_index(self, index): + text = self.editor.control.itemText(index) + self.editor.control.currentIndexChanged[str].emit(text) + + +if is_current_backend_wx(): + + from traitsui.wx.enum_editor import SimpleEditor as WxEnumEditor + + @simulate(WxEnumEditor, LOCAL_REGISTRY) + class WxCustomSimulator(BaseSimulator): + + 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]) +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) + + parent = Parent() + with tester, tester.create_ui(parent) as ui: + + self.assertEqual(parent.employment_status, "employed") + tester.set_editor_value( + ui, "employment_status", lambda s: s.click_some_index(1) + ) + self.assertEqual(parent.employment_status, "unemployed") + + # the default simulator from TraitsUI is still accessible. + tester.set_text(ui, "employment_status", "employed") + self.assertEqual(parent.employment_status, "employed") diff --git a/traitsui/testing/ui_tester.py b/traitsui/testing/ui_tester.py new file mode 100644 index 000000000..51648da20 --- /dev/null +++ b/traitsui/testing/ui_tester.py @@ -0,0 +1,320 @@ + +from contextlib import contextmanager + +from pyface.gui import GUI + +from traitsui.testing.simulation import ( + get_editor_value, set_editor_value, DEFAULT_REGISTRY, +) +from traitsui.tests._tools import store_exceptions_on_all_threads + + +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. Inspection methods are also defined. + + 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. + """ + + def __init__(self, registries=None): + """ Initialize a tester for testing GUI and traits interaction. + + Parameters + ---------- + registries : list of SimulatorRegistry, optional + Registries of simulators for different editors, in the order + 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] + else: + self._registries = registries.copy() + + 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() + + 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. + + ``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 = {} + ui = object.edit_traits(**ui_kwargs) + try: + yield ui + finally: + ui.dispose() + + def get_text(self, ui, name): + """ Retrieve a text 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 text. + + 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, + setter=lambda s: s.set_text(text, confirmed=confirmed) + ) + + 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) + ) + + def set_editor_value(self, ui, name, setter): + """ General method for setting 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" + 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, registries=self._registries) + self.gui.process_events() + + 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, 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/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. diff --git a/traitsui/wx/enum_editor.py b/traitsui/wx/enum_editor.py index bb7894d62..acf0a0119 100644 --- a/traitsui/wx/enum_editor.py +++ b/traitsui/wx/enum_editor.py @@ -29,6 +29,9 @@ # traitsui.editors.drop_editor file. 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 from .constants import OKColor, ErrorColor @@ -324,6 +327,33 @@ def rebuild_editor(self): self.update_editor() +@simulate(SimpleEditor, registry=DEFAULT_REGISTRY) +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) + + 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..390b854c0 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,8 @@ 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 traitsui.testing.simulation import DEFAULT_REGISTRY from . import toolkit from .editor import Editor @@ -479,6 +482,20 @@ def _view_changed(self, view): self.resynch_editor() +@simulate(CustomEditor, registry=DEFAULT_REGISTRY) +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 +526,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 +550,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, registry=DEFAULT_REGISTRY) +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..f6388ce70 100644 --- a/traitsui/wx/text_editor.py +++ b/traitsui/wx/text_editor.py @@ -34,6 +34,9 @@ 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: HoverColor = wx.LIGHT_GREY @@ -177,6 +180,19 @@ class CustomEditor(SimpleEditor): base_style = wx.TE_MULTILINE +@simulate(CustomEditor, registry=DEFAULT_REGISTRY) +@simulate(SimpleEditor, registry=DEFAULT_REGISTRY) +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. """