diff --git a/integrationtests/test_all_examples.py b/integrationtests/test_all_examples.py index 5c3d7b6af..ed6d79419 100644 --- a/integrationtests/test_all_examples.py +++ b/integrationtests/test_all_examples.py @@ -30,6 +30,7 @@ reraise_exceptions, ToolkitName, ) +from traitsui.testing.api import command, locator, query, UITester # This test file is not distributed nor is it in a package. HERE = os.path.dirname(__file__) @@ -279,6 +280,14 @@ def run_file(file_path): exec(content, globals) +def load_demo(file_path, variable_name="demo"): + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + globals_ = globals().copy() + exec(content, globals_) + return globals_[variable_name] + + # ============================================================================= # Test cases # ============================================================================= @@ -317,3 +326,154 @@ def test_run(self): reason=reason, file_path=file_path ) ) + + +class TestInteractExample(unittest.TestCase): + """ Test examples with more interactions.""" + + @requires_toolkit([ToolkitName.qt, ToolkitName.wx]) + def test_run_auto_editable_readonly_table_cells(self): + # Test Auto_editable_readonly_table_cells in examples/demos/Advanced + filepath = os.path.join( + DEMO, "Advanced", "Auto_editable_readonly_table_cells.py" + ) + demo = load_demo(filepath) + tester = UITester() + with tester.create_ui(demo) as ui: + range_editor = tester.find_by_name(ui, "max_n") + text = range_editor.locate(locator.WidgetType.textbox) + text.perform(command.KeySequence("\b\b3")) + text.perform(command.KeyClick("Enter")) + + self.assertEqual(demo.max_n, 3) + + slider = range_editor.locate(locator.WidgetType.slider) + for _ in range(5): + slider.perform(command.KeyClick("Right")) + + self.assertEqual(demo.max_n, 53) + + text.perform(command.KeySequence("\b\b\b40")) + text.perform(command.KeyClick("Enter")) + + for _ in range(5): + slider.perform(command.KeyClick("Left")) + + self.assertEqual(demo.max_n, 1) + + @requires_toolkit([ToolkitName.qt]) + def test_run_list_editors_demo(self): + # Test List_editors_demo in examples/demos/Advanced + filepath = os.path.join( + DEMO, "Advanced", "List_editors_demo.py" + ) + demo = load_demo(filepath) + tester = UITester() + with tester.create_ui(demo) as ui: + main_tab = tester.find_by_id(ui, "splitter") + + # List tab + main_tab.locate(locator.Index(1)).perform(command.MouseClick()) + item = tester.find_by_id(ui, "list").locate(locator.Index(7)) + item.find_by_name("name").perform( + command.KeySequence("\b\b\b\b\b\bDavid") + ) + self.assertEqual(demo.people[7].name, "David") + + # Notebook tab + main_tab.locate(locator.Index(2)).perform(command.MouseClick()) + notebook = tester.find_by_id(ui, "notebook") + notebook.locate(locator.Index(1)).perform(command.MouseClick()) + name_field = notebook.locate(locator.Index(1)).find_by_name("name") + name_field.perform(command.KeySequence("\b\b\b\bSimon")) + self.assertEqual(demo.people[1].name, "Simon") + + # Table tab + main_tab.locate(locator.Index(0)).perform(command.MouseClick()) + table = tester.find_by_id(ui, "table") + + # Pick a person object + person = demo.people[6] + + # Find the row that refers to this object. + # The view has sorted the items. + for i in range(len(demo.people)): + name_cell = table.locate(locator.Cell(i, 0)) + displayed = name_cell.inspect(query.DisplayedText()) + if displayed == person.name: + break + else: + self.fail( + "Could not find the row for {!r}".format(person.name) + ) + + age_cell = table.locate(locator.Cell(i, 1)) + age_cell.perform(command.MouseClick()) + age_cell.perform(command.KeySequence("50")) + self.assertEqual(person.age, 50) + + @requires_toolkit([ToolkitName.qt]) + def test_run_tree_editor_demo(self): + # Test TreeEditor_demo in examples/demo/Standard_Editors + filepath = os.path.join( + DEMO, "Standard_Editors", "TreeEditor_demo.py" + ) + demo = load_demo(filepath) + tester = UITester() + with tester.create_ui(demo) as ui: + root_actor = tester.find_by_name(ui, "company") + + # Enthought->Department->Business->(First employee) + node = root_actor.locate(locator.TreeNode((0, 0, 0, 0), 0)) + node.perform(command.MouseClick()) + + name_actor = root_actor.find_by_name("name") + name_actor.perform(command.KeySequence("\b\b\b\b\bJames")) + self.assertEqual( + demo.company.departments[0].employees[0].name, + "James", + ) + + # Enthought->Department->Scientific + demo.company.departments[1].name = "Scientific Group" + node = root_actor.locate(locator.TreeNode((0, 0, 1), 0)) + self.assertEqual( + node.inspect(query.DisplayedText()), "Scientific Group" + ) + + # Enthought->Department->Business + node = root_actor.locate(locator.TreeNode((0, 0, 0), 0)) + node.perform(command.MouseClick()) + node.perform(command.MouseDClick()) + + name_actor = root_actor.find_by_name("name") + name_actor.perform(command.KeySequence(" Group")) + self.assertEqual( + demo.company.departments[0].name, + "Business Group", + ) + + @requires_toolkit([ToolkitName.qt, ToolkitName.wx]) + def test_converter(self): + # Test converter.py in examples/demo/Applications + filepath = os.path.join( + DEMO, "Applications", "converter.py" + ) + demo = load_demo(filepath, "popup") + tester = UITester() + with tester.create_ui(demo) as ui: + input_amount = tester.find_by_name(ui, "input_amount") + output_amount = tester.find_by_name(ui, "output_amount") + + input_amount.perform(command.KeySequence("\b\b\b\b14.0")) + self.assertEqual( + output_amount.inspect(query.DisplayedText())[:4], + "1.16", + ) + + tester.find_by_id(ui, "Undo").perform(command.MouseClick()) + + self.assertEqual( + output_amount.inspect(query.DisplayedText()), + "1.0", + ) diff --git a/traitsui/examples/demo/Advanced/Auto_editable_readonly_table_cells.py b/traitsui/examples/demo/Advanced/Auto_editable_readonly_table_cells.py index 0f0285590..54f0c03c8 100644 --- a/traitsui/examples/demo/Advanced/Auto_editable_readonly_table_cells.py +++ b/traitsui/examples/demo/Advanced/Auto_editable_readonly_table_cells.py @@ -167,4 +167,4 @@ def _get_factors(self): # Run the demo (if invoked from the command line): if __name__ == '__main__': - demo.configure_traits() + demo.configure_traits() \ No newline at end of file diff --git a/traitsui/qt4/ui_panel.py b/traitsui/qt4/ui_panel.py index c05fcca01..082acb881 100644 --- a/traitsui/qt4/ui_panel.py +++ b/traitsui/qt4/ui_panel.py @@ -335,6 +335,8 @@ def _size_hint_wrapper(f, ui): def sizeHint(): size = f() + if ui.view is None: + return size if ui.view.width > 0: size.setWidth(ui.view.width) if ui.view.height > 0: 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..dbdeb7da6 --- /dev/null +++ b/traitsui/testing/api.py @@ -0,0 +1,25 @@ +from traitsui.testing.exceptions import ( # noqa: F401 + Disabled, +) +from traitsui.testing import command # noqa: F401 +from traitsui.testing.command import ( # noqa: F401 + KeyClick, + KeySequence, + MouseClick, +) +from traitsui.testing import locator # noqa: F401 +from traitsui.testing.locator import ( # noqa: F401 + Cell, + Index, +) +from traitsui.testing import query # noqa: F401 +from traitsui.testing.query import ( # noqa: F401 + DisplayedText, +) +from traitsui.testing.interactor_registry import ( # noqa: F401 + InteractionRegistry, +) + +from traitsui.testing.ui_tester import ( # noqa: F401 + UITester, +) diff --git a/traitsui/testing/command.py b/traitsui/testing/command.py new file mode 100644 index 000000000..34ffcd68e --- /dev/null +++ b/traitsui/testing/command.py @@ -0,0 +1,56 @@ +""" This module defines action objects that can be passed to +``UITester.perform`` where the actions represent 'commands'. + +Implementations for these actions are expected to produce the +documented side effects without returning any values. +""" + + +class MouseClick: + """ An object representing the user clicking a mouse button. + Currently the left mouse button is assumed. + + In most circumstances, a widget can still be clicked on even if it is + disabled. Therefore unlike key events, if the widget is disabled, + implementations should not raise an exception. + """ + pass + + +class MouseDClick: + """ An object representing the user double clicking a mouse button. + Currently the left mouse button is assumed. + """ + pass + + +class KeySequence: + """ An object representing the user typing a sequence of keys. + + Implementations should raise ``Disabled`` if the widget is disabled. + + Attribute + --------- + sequence : str + A string that represents a sequence of key inputs. + e.g. "Hello World" + """ + + def __init__(self, sequence): + self.sequence = sequence + + +class KeyClick: + """ An object representing the user clicking a key on the keyboard. + + Implementations should raise ``Disabled`` if the widget is disabled. + + Attribute + --------- + key : str + Standardized (pyface) name for a keyboard event. + e.g. "Enter", "Tab", "Space", "0", "1", "A", ... + """ + + def __init__(self, key): + self.key = key diff --git a/traitsui/testing/default_registry.py b/traitsui/testing/default_registry.py new file mode 100644 index 000000000..0ccdd1a96 --- /dev/null +++ b/traitsui/testing/default_registry.py @@ -0,0 +1,97 @@ +import importlib + +from pyface.base_toolkit import find_toolkit + +from traitsui.ui import UI +from traitsui.testing import locator, command +from traitsui.testing.interactor_registry import InteractionRegistry + + +def get_default_registries(): + # side-effect to determine current toolkit + package = find_toolkit("traitsui.testing") + module = importlib.import_module(".default_registry", package.__name__) + return [ + module.get_default_registry(), + ] + + +def _get_editor_by_id(ui, id): + """ Return aan editor identified by a id. + + Parameters + ---------- + ui : traitsui.ui.UI + The UI from which an editor will be retrieved. + id : str + Id for finding an item in the UI. + """ + try: + editor = getattr(ui.info, id) + except AttributeError: + raise ValueError( + "No editors found with id {!r}. Got these: {!r}".format( + id, ui._names) + ) + return editor + + +def _get_editor_by_name(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 name for retreiving an editor on a UI. + + Returns + ------- + editor : Editor + The single editor found. + """ + editors = ui.get_editors(name) + + all_names = [editor.name for editor in ui._editors] + if not editors: + raise ValueError( + "No editors can be found with name {!r}. " + "Found these: {!r}".format(name, all_names) + ) + if len(editors) > 1: + raise ValueError( + "Found multiple editors with name {!r}.".format(name)) + editor, = editors + return editor + + +def _resolve_ui_editor_by_id(wrapper, location): + return _get_editor_by_id(wrapper.editor, location.id) + + +def _resolve_ui_editor_by_name(wrapper, location): + return _get_editor_by_name(wrapper.editor, location.name) + + +def get_ui_registry(): + """ Return a registry for traitsui.ui.UI only. + """ + registry = InteractionRegistry() + registry.register_location_solver( + target_class=UI, + locator_class=locator.TargetById, + solver=_resolve_ui_editor_by_id, + ) + registry.register_location_solver( + target_class=UI, + locator_class=locator.TargetByName, + solver=_resolve_ui_editor_by_name, + ) + registry.register_location_solver( + target_class=UI, + locator_class=locator.NestedUI, + solver=lambda wrapper, _: wrapper.editor, + ) + return registry diff --git a/traitsui/testing/exceptions.py b/traitsui/testing/exceptions.py new file mode 100644 index 000000000..8cbf751d9 --- /dev/null +++ b/traitsui/testing/exceptions.py @@ -0,0 +1,47 @@ + +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 + + +class ActionNotSupported(SimulationError): + """ Raised when an action is not supported by an wrapper. + """ + + def __init__(self, target_class, interaction_class, supported): + self.target_class = target_class + self.interaction_class = interaction_class + self.supported = supported + + def __str__(self): + return ( + "No handler is found for editor {!r} with action {!r}. " + "Supported these: {!r}".format( + self.target_class, self.interaction_class, self.supported + ) + ) + + +class LocationNotSupported(SimulationError): + """ Raised when attempt to resolve a location on a UI fails + because the location type is not supported. + """ + + def __init__(self, target_class, locator_class, supported): + self.target_class = target_class + self.locator_class = locator_class + self.supported = supported + + def __str__(self): + return ( + "Location {!r} is not supported for {!r}. " + "Supported these: {!r}".format( + self.locator_class, self.target_class, self.supported + ) + ) diff --git a/traitsui/testing/interactor_registry.py b/traitsui/testing/interactor_registry.py new file mode 100644 index 000000000..bbe7dd135 --- /dev/null +++ b/traitsui/testing/interactor_registry.py @@ -0,0 +1,89 @@ +from traitsui.api import Editor + +from traitsui.testing.exceptions import ( + ActionNotSupported, LocationNotSupported, +) + + +class InteractionRegistry: + + def __init__(self): + self.editor_to_action_to_handler = {} + self.editor_to_location_solver = {} + + def register(self, target_class, interaction_class, handler, force=False): + action_to_handler = self.editor_to_action_to_handler.setdefault( + target_class, {} + ) + if interaction_class in action_to_handler and not force: + raise ValueError( + "A handler for editor {!r} and action type {!r} already " + "exists and 'force' is set to false.".format( + target_class, + interaction_class, + ) + ) + action_to_handler[interaction_class] = handler + + def get_handler(self, target_class, interaction_class): + if target_class not in self.editor_to_action_to_handler: + raise ActionNotSupported( + target_class=target_class, + interaction_class=interaction_class, + supported=[], + ) + action_to_handler = self.editor_to_action_to_handler[target_class] + if interaction_class not in action_to_handler: + raise ActionNotSupported( + target_class=target_class, + interaction_class=interaction_class, + supported=list(action_to_handler), + ) + return action_to_handler[interaction_class] + + def register_location_solver(self, target_class, locator_class, solver): + """ Register a callable for resolving location. + + Parameters + ---------- + target_class : subclass of traitsui.editor.Editor + locator_class : type + solver : callable(UIWrapper, location) -> any + A callable for resolving a location into a new target. + The location argument will be an instance of locator_class. + """ + locator_to_solver = self.editor_to_location_solver.setdefault( + target_class, {} + ) + if locator_class in locator_to_solver: + raise ValueError( + "A solver already exists for {!r} with locator {!r}".format( + target_class, locator_class, + ) + ) + + locator_to_solver[locator_class] = solver + + def get_location_solver(self, target_class, locator_class): + """ Return a solver for the given location. + + Parameters + ---------- + target_class : subclass of traitsui.editor.Editor + locator_class : type + + Raises + ------ + LocationNotSupported + """ + locator_to_solver = self.editor_to_location_solver.get( + target_class, {} + ) + try: + return locator_to_solver[locator_class] + except KeyError: + raise LocationNotSupported( + target_class=target_class, + locator_class=locator_class, + supported=list(locator_to_solver), + ) diff --git a/traitsui/testing/locator.py b/traitsui/testing/locator.py new file mode 100644 index 000000000..98c990d82 --- /dev/null +++ b/traitsui/testing/locator.py @@ -0,0 +1,68 @@ +import enum + + +class Cell: + """ A location uniquely specified by a row index and a column index. + + Attributes + ---------- + row : int + 0-based index + column : int + 0-based index + """ + + def __init__(self, row, column): + self.row = row + self.column = column + + +class Index: + """ A location uniquely specified by a single 0-based index. + + Attributes + ---------- + index : int + 0-based index + column : int + 0-based index + """ + + def __init__(self, index): + self.index = index + + +class TreeNode: + + def __init__(self, row, column): + self.row = row + self.column = column + + +class WidgetType(enum.Enum): + """ An Enum of widget types. + """ + + slider = "slider" + textbox = "textbox" + tabbar = "tabbar" + + +class TargetById: + + def __init__(self, id): + self.id = id + + +class TargetByName: + + def __init__(self, name): + self.name = name + + +class NestedUI: + pass + + +class DefaultTarget: + pass diff --git a/traitsui/testing/qt4/__init__.py b/traitsui/testing/qt4/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/traitsui/testing/qt4/default_registry.py b/traitsui/testing/qt4/default_registry.py new file mode 100644 index 000000000..a9e8dfee5 --- /dev/null +++ b/traitsui/testing/qt4/default_registry.py @@ -0,0 +1,180 @@ + +from pyface.qt import QtCore, QtGui +from pyface.qt.QtTest import QTest + +from traitsui.api import ( + CheckListEditor, + EnumEditor, + ListEditor, + TreeEditor, +) +from traitsui.qt4.instance_editor import ( + SimpleEditor as SimpleInstanceEditor, + CustomEditor as CustomInstanceEditor, +) +from traitsui.qt4.list_editor import ( + CustomEditor as CustomListEditor, + NotebookEditor as NotebookListEditor, +) +from traitsui.qt4.range_editor import SimpleSliderEditor +from traitsui.qt4.tree_editor import SimpleEditor as SimpleTreeEditor +from traitsui.qt4.ui_panel import TabbedFoldGroupEditor +from traitsui.testing import command +from traitsui.testing import query +from traitsui.testing import locator +from traitsui.testing import registry_helper +from traitsui.testing.exceptions import Disabled +from traitsui.testing.qt4 import helpers +from traitsui.testing.qt4.implementation import ( + button_editor, + check_list_editor, + enum_editor, + group_editor, + list_editor, + table_editor, + text_editor, + tree_editor, + ui_base, +) +from traitsui.testing.interactor_registry import InteractionRegistry + + +def resolve_location_simple_editor(wrapper, location): + if wrapper.editor._dialog_ui is None: + wrapper.editor._button.click() + return wrapper.editor._dialog_ui + + +def resolve_location_custom_instance_editor(wrapper, location): + return wrapper.editor._ui + + +def resolve_location_range_editor(wrapper, location): + type_to_widget = { + locator.WidgetType.slider: wrapper.editor.control.slider, + locator.WidgetType.textbox: wrapper.editor.control.text, + } + return type_to_widget[location] + + +def key_sequence_qwidget(wrapper, action): + if not wrapper.editor.isEnabled(): + raise Disabled("{!r} is disabled.".format(wrapper.editor)) + QTest.keyClicks(wrapper.editor, action.sequence, delay=wrapper.delay) + + +def key_press_qwidget(wrapper, action): + if not wrapper.editor.isEnabled(): + raise Disabled("{!r} is disabled.".format(wrapper.editor)) + helpers.key_press( + wrapper.editor, action.key, delay=wrapper.delay + ) + + +def mouse_click_qwidget(wrapper, action): + QTest.mouseClick( + wrapper.editor, + QtCore.Qt.LeftButton, + delay=wrapper.delay, + ) + + +def get_default_registry(): + """ Return the default registry of implementations for Qt editors. + + Returns + ------- + registry : InteractionRegistry + """ + registry = get_generic_registry() + + # ButtonEditor + button_editor.register(registry) + + # TextEditor + text_editor.register(registry) + + # CheckListEditor + check_list_editor.register(registry) + + # EnumEditor + enum_editor.register(registry) + + # InstanceEditor + registry.register_location_solver( + target_class=SimpleInstanceEditor, + locator_class=locator.DefaultTarget, + solver=resolve_location_simple_editor, + ) + registry.register_location_solver( + target_class=CustomInstanceEditor, + locator_class=locator.DefaultTarget, + solver=resolve_location_custom_instance_editor, + ) + + # TableEditor + table_editor.register(registry) + + # TreeEditor + tree_editor.register(registry) + + # ListEditor + list_editor.register_list_editor(registry) + + # Tabbed in the UI + group_editor.register_tabbed_fold_group_editor(registry) + + # RangeEditor (slider) + registry.register_location_solver( + target_class=SimpleSliderEditor, + locator_class=locator.WidgetType, + solver=resolve_location_range_editor, + ) + + # UI buttons + ui_base.register(registry) + return registry + + +def get_generic_registry(): + registry = InteractionRegistry() + widget_classes = [ + QtGui.QLineEdit, + QtGui.QSlider, + QtGui.QTextEdit, + QtGui.QPushButton, + ] + handlers = [ + (command.KeySequence, key_sequence_qwidget), + (command.KeyClick, key_press_qwidget), + (command.MouseClick, mouse_click_qwidget), + ] + for widget_class in widget_classes: + for interaction_class, handler in handlers: + registry.register( + target_class=widget_class, + interaction_class=interaction_class, + handler=handler, + ) + + registry.register( + target_class=QtGui.QPushButton, + interaction_class=query.DisplayedText, + handler=lambda wrapper, _: wrapper.editor.text(), + ) + registry.register( + target_class=QtGui.QLineEdit, + interaction_class=query.DisplayedText, + handler=lambda wrapper, _: wrapper.editor.displayText(), + ) + registry.register( + target_class=QtGui.QTextEdit, + interaction_class=query.DisplayedText, + handler=lambda wrapper, _: wrapper.editor.toPlainText(), + ) + registry.register( + target_class=QtGui.QLabel, + interaction_class=query.DisplayedText, + handler=lambda wrapper, _: wrapper.editor.text(), + ) + return registry diff --git a/traitsui/testing/qt4/helpers.py b/traitsui/testing/qt4/helpers.py new file mode 100644 index 000000000..72f812860 --- /dev/null +++ b/traitsui/testing/qt4/helpers.py @@ -0,0 +1,249 @@ + +from functools import reduce + +from pyface.qt import QtCore +from pyface.qt.QtTest import QTest +from traitsui.qt4.key_event_to_name import key_map as _KEY_MAP +from traitsui.testing.exceptions import Disabled + + +def get_displayed_text(widget): + try: + return widget.displayText() + except AttributeError: + return widget.toPlainText() + + +def key_press(widget, key, delay=0): + if "-" in key: + *modifiers, key = key.split("-") + else: + modifiers = [] + + modifier_to_qt = { + "Ctrl": QtCore.Qt.ControlModifier, + "Alt": QtCore.Qt.AltModifier, + "Meta": QtCore.Qt.MetaModifier, + "Shift": QtCore.Qt.ShiftModifier, + } + qt_modifiers = [modifier_to_qt[modifier] for modifier in modifiers] + qt_modifier = reduce( + lambda x, y: x | y, qt_modifiers, QtCore.Qt.NoModifier + ) + + mapping = {name: event for event, name in _KEY_MAP.items()} + if key not in mapping: + raise ValueError( + "Unknown key {!r}. Expected one of these: {!r}".format( + key, sorted(mapping) + )) + QTest.keyClick( + widget, + mapping[key], + qt_modifier, + delay=delay, + ) + + +def check_q_model_index_valid(index): + if not index.isValid(): + row = index.row() + column = index.column() + raise LookupError( + "Unabled to locate item with row {!r} and column {!r}.".format( + row, column, + ) + ) + + +def mouse_click_item_view(model, view, index, delay=0): + """ Perform mouse click on the given QAbstractItemModel (model) and + QAbstractItemView (view) with the given row and column. + + Parameters + ---------- + model : QAbstractItemModel + Model from which QModelIndex will be obtained + view : QAbstractItemView + View from which the widget identified by the index will be + found and key sequence be performed. + index : QModelIndex + + Raises + ------ + LookupError + If the index cannot be located. + Note that the index error provides more + """ + check_q_model_index_valid(index) + rect = view.visualRect(index) + QTest.mouseClick( + view.viewport(), + QtCore.Qt.LeftButton, + QtCore.Qt.NoModifier, + rect.center(), + delay=delay, + ) + + +def mouse_dclick_item_view(model, view, index, delay=0): + """ Perform mouse double click on the given QAbstractItemModel (model) and + QAbstractItemView (view) with the given row and column. + + Parameters + ---------- + model : QAbstractItemModel + Model from which QModelIndex will be obtained + view : QAbstractItemView + View from which the widget identified by the index will be + found and key sequence be performed. + index : QModelIndex + + Raises + ------ + LookupError + If the index cannot be located. + Note that the index error provides more + """ + check_q_model_index_valid(index) + rect = view.visualRect(index) + QTest.mouseDClick( + view.viewport(), + QtCore.Qt.LeftButton, + QtCore.Qt.NoModifier, + rect.center(), + delay=delay, + ) + + +def key_sequence_item_view(model, view, index, sequence, delay=0): + """ Perform mouse click on the given QAbstractItemModel (model) and + QAbstractItemView (view) with the given row and column. + + Parameters + ---------- + model : QAbstractItemModel + Model from which QModelIndex will be obtained + view : QAbstractItemView + View from which the widget identified by the index will be + found and key sequence be performed. + index : QModelIndex + sequence : str + Sequence of characters to be inserted to the widget identifed + by the row and column. + + Raises + ------ + Disabled + If the widget cannot be edited. + LookupError + If the index cannot be located. + Note that the index error provides more + """ + check_q_model_index_valid(index) + widget = view.indexWidget(index) + if widget is None: + raise Disabled( + "No editable widget for item at row {!r} and column {!r}".format( + index.row(), index.column() + ) + ) + QTest.keyClicks(widget, sequence, delay=delay) + + +def key_press_item_view(model, view, index, key, delay=0): + """ Perform key press on the given QAbstractItemModel (model) and + QAbstractItemView (view) with the given row and column. + + Parameters + ---------- + model : QAbstractItemModel + Model from which QModelIndex will be obtained + view : QAbstractItemView + View from which the widget identified by the index will be + found and key press be performed. + index : int + key : str + Key to be pressed. + + Raises + ------ + Disabled + If the widget cannot be edited. + LookupError + If the index cannot be located. + Note that the index error provides more + """ + check_q_model_index_valid(index) + widget = view.indexWidget(index) + if widget is None: + raise Disabled( + "No editable widget for item at row {!r} and column {!r}".format( + index.row(), index.column() + ) + ) + key_press(widget, key=key, delay=delay) + + +def get_display_text_item_view(model, view, index): + """ Return the textural representation for the given model, row and column. + + Parameters + ---------- + model : QAbstractItemModel + Model from which QModelIndex will be obtained + view : QAbstractItemView + View from which the widget identified by the index will be + found and key press be performed. + index : int + + Raises + ------ + LookupError + If the index cannot be located. + Note that the index error provides more + """ + check_q_model_index_valid(index) + return model.data(index, QtCore.Qt.DisplayRole) + + +def mouse_click_tab_index(tab_widget, index, delay=0): + tabbar = tab_widget.tabBar() + rect = tabbar.tabRect(index) + QTest.mouseClick( + tabbar, + QtCore.Qt.LeftButton, + QtCore.Qt.NoModifier, + rect.center(), + delay=delay, + ) + + +def mouse_click_qlayout(layout, index, delay=0): + """ Performing a mouse click on an index in a QLayout + """ + if not 0 <= index < layout.count(): + raise IndexError(index) + widget = layout.itemAt(index).widget() + QTest.mouseClick( + widget, + QtCore.Qt.LeftButton, + delay=delay, + ) + + +def mouse_click_combobox(combobox, index, delay=0): + """ Perform a mouse click on a QComboBox. + """ + q_model_index = combobox.model().index(index, 0) + check_q_model_index_valid(q_model_index) + mouse_click_item_view( + model=combobox.model(), + view=combobox.view(), + index=q_model_index, + delay=delay, + ) + # Otherwise the click won't get registered. + key_press( + combobox.view().viewport(), key="Enter", delay=delay, + ) diff --git a/traitsui/testing/qt4/implementation/__init__.py b/traitsui/testing/qt4/implementation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/traitsui/testing/qt4/implementation/button_editor.py b/traitsui/testing/qt4/implementation/button_editor.py new file mode 100644 index 000000000..4eab50873 --- /dev/null +++ b/traitsui/testing/qt4/implementation/button_editor.py @@ -0,0 +1,19 @@ +from traitsui.testing import locator +from traitsui.qt4.button_editor import CustomEditor, SimpleEditor + + +def register(registry): + """ Register actions for the given registry. + + If there are any conflicts, an error will occur. + """ + registry.register_location_solver( + target_class=SimpleEditor, + locator_class=locator.DefaultTarget, + solver=lambda wrapper, _: wrapper.editor.control, + ) + registry.register_location_solver( + target_class=CustomEditor, + locator_class=locator.DefaultTarget, + solver=lambda wrapper, _: wrapper.editor.control, + ) diff --git a/traitsui/testing/qt4/implementation/check_list_editor.py b/traitsui/testing/qt4/implementation/check_list_editor.py new file mode 100644 index 000000000..57f1efc9f --- /dev/null +++ b/traitsui/testing/qt4/implementation/check_list_editor.py @@ -0,0 +1,45 @@ +from traitsui.qt4.check_list_editor import CustomEditor +from traitsui.testing import command, locator +from traitsui.testing.qt4 import helpers + + +class _IndexedCustomCheckListEditor: + """ Wrapper for CheckListEditor + locator.Index """ + + def __init__(self, editor, index): + self.editor = editor + self.index = index + + @classmethod + def from_location_index(cls, wrapper, location): + # Conform to the call signature specified in the register + return cls( + editor=wrapper.editor, + index=location.index, + ) + + @classmethod + def register(cls, registry): + registry.register_location_solver( + target_class=CustomEditor, + locator_class=locator.Index, + solver=cls.from_location_index, + ) + registry.register( + target_class=cls, + interaction_class=command.MouseClick, + handler=lambda wrapper, _: wrapper.editor.mouse_click( + delay=wrapper.delay, + ) + ) + + def mouse_click(self, delay=0): + helpers.mouse_click_qlayout( + layout=self.editor.control.layout(), + index=self.index, + delay=delay, + ) + + +def register(registry): + _IndexedCustomCheckListEditor.register(registry) diff --git a/traitsui/testing/qt4/implementation/enum_editor.py b/traitsui/testing/qt4/implementation/enum_editor.py new file mode 100644 index 000000000..9959969ff --- /dev/null +++ b/traitsui/testing/qt4/implementation/enum_editor.py @@ -0,0 +1,131 @@ +from traitsui.qt4.enum_editor import ( + ListEditor, + RadioEditor, + SimpleEditor, +) +from traitsui.testing import command, locator +from traitsui.testing.qt4 import helpers + + +class _IndexedListEditor: + """ Wrapper for (list) EnumEditor and index """ + + def __init__(self, editor, index): + self.editor = editor + self.index = index + + @classmethod + def from_location(cls, wrapper, location): + return cls( + editor=wrapper.editor, + index=location.index, + ) + + @classmethod + def register(cls, registry): + registry.register_location_solver( + target_class=ListEditor, + locator_class=locator.Index, + solver=cls.from_location, + ) + registry.register( + target_class=cls, + interaction_class=command.MouseClick, + handler=lambda wrapper, _: ( + wrapper.editor.mouse_click(delay=wrapper.delay) + ) + ) + + def mouse_click(self, delay=0): + list_widget = self.editor.control + helpers.mouse_click_item_view( + model=list_widget.model(), + view=list_widget, + index=list_widget.model().index(self.index, 0) + ) + + +class _IndexedRadioEditor: + """ Wrapper for RadioEditor and an index. + """ + + def __init__(self, editor, index): + self.editor = editor + self.index = index + + @classmethod + def from_location(cls, wrapper, location): + return cls( + editor=wrapper.editor, + index=location.index, + ) + + @classmethod + def register(cls, registry): + registry.register_location_solver( + target_class=RadioEditor, + locator_class=locator.Index, + solver=cls.from_location, + ) + registry.register( + target_class=cls, + interaction_class=command.MouseClick, + handler=lambda wrapper, _: wrapper.editor.mouse_click( + delay=wrapper.delay, + ), + ) + + def mouse_click(self, delay=0): + helpers.mouse_click_qlayout( + layout=self.editor.control.layout(), + index=self.index, + ) + + +class _IndexedSimpleEditor: + """ Wrapper for (simple) EnumEditor and an index.""" + + def __init__(self, editor, index): + self.editor = editor + self.index = index + + @classmethod + def register(cls, registry): + registry.register_location_solver( + target_class=SimpleEditor, + locator_class=locator.Index, + solver=cls.from_location, + ) + registry.register( + target_class=cls, + interaction_class=command.MouseClick, + handler=lambda wrapper, _: wrapper.editor.mouse_click( + delay=wrapper.delay, + ), + ) + + @classmethod + def from_location(cls, wrapper, location): + return cls( + editor=wrapper.editor, + index=location.index, + ) + + def mouse_click(self, delay=0): + helpers.mouse_click_combobox( + combobox=self.editor.control, + index=self.index, + delay=delay, + ) + + +def register(registry): + """ Registry location and interaction handlers for EnumEditor. + + Parameters + ---------- + registry : InteractionRegistry + """ + _IndexedListEditor.register(registry) + _IndexedRadioEditor.register(registry) + _IndexedSimpleEditor.register(registry) diff --git a/traitsui/testing/qt4/implementation/group_editor.py b/traitsui/testing/qt4/implementation/group_editor.py new file mode 100644 index 000000000..e4189b745 --- /dev/null +++ b/traitsui/testing/qt4/implementation/group_editor.py @@ -0,0 +1,40 @@ + +from traitsui.qt4.ui_panel import TabbedFoldGroupEditor +from traitsui.testing import command, locator + + +class _IndexedTabbedFoldGroupEditor: + + def __init__(self, editor, index): + # TabbedFoldGroupEditor could hold a Toolbar as well. + qtab_or_qtoolbox = editor.container + if qtab_or_qtoolbox.widget(index) is None: + raise IndexError(index) + self.editor = editor + self.index = index + + def mouse_click(self, delay=0): + # Not actually mouse click, but setCurrentIndex is available + # for both QToolbar and QTabWidget + self.editor.container.setCurrentIndex(self.index) + + @classmethod + def from_location_index(cls, wrapper, location): + return cls( + editor=wrapper.editor, index=location.index, + ) + + +def register_tabbed_fold_group_editor(registry): + registry.register_location_solver( + target_class=TabbedFoldGroupEditor, + locator_class=locator.Index, + solver=_IndexedTabbedFoldGroupEditor.from_location_index, + ) + registry.register( + target_class=_IndexedTabbedFoldGroupEditor, + interaction_class=command.MouseClick, + handler=lambda wrapper, _: ( + wrapper.editor.mouse_click(delay=wrapper.delay) + ) + ) diff --git a/traitsui/testing/qt4/implementation/list_editor.py b/traitsui/testing/qt4/implementation/list_editor.py new file mode 100644 index 000000000..db6134205 --- /dev/null +++ b/traitsui/testing/qt4/implementation/list_editor.py @@ -0,0 +1,109 @@ +from pyface.qt.QtTest import QTest + +from traitsui.qt4.list_editor import ( + CustomEditor, + NotebookEditor, +) +from traitsui.testing import ( + command, + locator, + registry_helper, +) +from traitsui.testing.qt4.helpers import mouse_click_tab_index + + +class _IndexedNotebookEditor: + + def __init__(self, editor, index): + self.editor = editor + self.index = index + + @classmethod + def from_location(cls, wrapper, location): + # Raise IndexError early + wrapper.editor._uis[location.index] + return cls( + editor=wrapper.editor, + index=location.index, + ) + + @classmethod + def register(cls, registry): + registry.register_location_solver( + target_class=NotebookEditor, + locator_class=locator.Index, + solver=cls.from_location, + ) + registry.register_location_solver( + target_class=cls, + locator_class=locator.NestedUI, + solver=lambda wrapper, _: wrapper.editor.get_nested_ui(), + ) + registry_helper.register_find_by_in_nested_ui( + registry=registry, + target_class=cls, + ) + registry.register( + target_class=cls, + interaction_class=command.MouseClick, + handler=lambda wrapper, _: ( + wrapper.editor.mouse_click(delay=wrapper.delay) + ), + ) + + def get_nested_ui(self): + return self.editor._uis[self.index][1] + + def mouse_click(self, delay=0): + mouse_click_tab_index(self.editor.control, self.index, delay=delay) + + +class _IndexedCustomEditor: + """ Wrapper for a ListEditor (custom) with an index. + """ + + def __init__(self, editor, index): + self.editor = editor + self.index = index + + @classmethod + def from_location(cls, wrapper, location): + return cls( + editor=wrapper.editor, + index=location.index, + ) + + @classmethod + def register(cls, registry): + registry.register_location_solver( + target_class=CustomEditor, + locator_class=locator.Index, + solver=cls.from_location, + ) + registry.register_location_solver( + target_class=cls, + locator_class=locator.NestedUI, + solver=lambda wrapper, _: wrapper.editor._get_nested_ui(), + ) + registry_helper.register_find_by_in_nested_ui( + registry=registry, + target_class=cls, + ) + + def _get_nested_ui(self): + row, column = divmod(self.index, self.editor.factory.columns) + grid_layout = self.editor._list_pane.layout() + item = grid_layout.itemAtPosition(row, column) + if item is None: + raise IndexError(self.index) + if self.editor.scrollable: + self.editor.control.ensureWidgetVisible(item.widget()) + return item.widget()._editor._ui + + +def register_list_editor(registry): + # NotebookEditor + _IndexedNotebookEditor.register(registry) + + # CustomEditor + _IndexedCustomEditor.register(registry) diff --git a/traitsui/testing/qt4/implementation/table_editor.py b/traitsui/testing/qt4/implementation/table_editor.py new file mode 100644 index 000000000..a88a399c3 --- /dev/null +++ b/traitsui/testing/qt4/implementation/table_editor.py @@ -0,0 +1,92 @@ +from traitsui.qt4.table_editor import SimpleEditor +from traitsui.testing import command, locator, query +from traitsui.testing.qt4 import helpers + + +class _SimpleEditorWithCell: + """ Wrapper for (simple) TableEditor + Cell location.""" + def __init__(self, editor, cell): + self.editor = editor + self.cell = cell + + @classmethod + def from_location(cls, wrapper, location): + return cls( + editor=wrapper.editor, + cell=location, + ) + + @classmethod + def register(cls, registry): + registry.register_location_solver( + target_class=SimpleEditor, + locator_class=locator.Cell, + solver=cls.from_location, + ) + registry.register( + target_class=cls, + interaction_class=command.MouseClick, + handler=lambda wrapper, _: wrapper.editor._mouse_click( + delay=wrapper.delay, + ), + ) + registry.register( + target_class=cls, + interaction_class=command.KeySequence, + handler=lambda wrapper, action: wrapper.editor._key_sequence( + sequence=action.sequence, + delay=wrapper.delay, + ), + ) + registry.register( + target_class=cls, + interaction_class=command.KeyClick, + handler=lambda wrapper, action: wrapper.editor._key_press( + key=action.key, + delay=wrapper.delay, + ), + ) + registry.register( + target_class=cls, + interaction_class=query.DisplayedText, + handler=lambda wrapper, _: ( + wrapper.editor._get_displayed_text() + ), + ) + + def _get_model_view_index(self): + table_view = self.editor.table_view + return dict( + model=table_view.model(), + view=table_view, + index=table_view.model().index(self.cell.row, self.cell.column), + ) + + def _mouse_click(self, delay=0): + helpers.mouse_click_item_view( + **self._get_model_view_index(), + delay=delay, + ) + + def _key_sequence(self, sequence, delay=0): + helpers.key_sequence_item_view( + **self._get_model_view_index(), + sequence=sequence, + delay=delay, + ) + + def _key_press(self, key, delay=0): + helpers.key_press_item_view( + **self._get_model_view_index(), + key=key, + delay=delay, + ) + + def _get_displayed_text(self): + return helpers.get_display_text_item_view( + **self._get_model_view_index(), + ) + + +def register(registry): + _SimpleEditorWithCell.register(registry) diff --git a/traitsui/testing/qt4/implementation/text_editor.py b/traitsui/testing/qt4/implementation/text_editor.py new file mode 100644 index 000000000..886e7c83b --- /dev/null +++ b/traitsui/testing/qt4/implementation/text_editor.py @@ -0,0 +1,24 @@ +from traitsui.testing import locator +from traitsui.qt4.text_editor import CustomEditor, SimpleEditor, ReadonlyEditor + + +def register(registry): + """ Register actions for the given registry. + + If there are any conflicts, an error will occur. + """ + registry.register_location_solver( + target_class=SimpleEditor, + locator_class=locator.DefaultTarget, + solver=lambda wrapper, _: wrapper.editor.control, + ) + registry.register_location_solver( + target_class=CustomEditor, + locator_class=locator.DefaultTarget, + solver=lambda wrapper, _: wrapper.editor.control, + ) + registry.register_location_solver( + target_class=ReadonlyEditor, + locator_class=locator.DefaultTarget, + solver=lambda wrapper, _: wrapper.editor.control, + ) diff --git a/traitsui/testing/qt4/implementation/tree_editor.py b/traitsui/testing/qt4/implementation/tree_editor.py new file mode 100644 index 000000000..24a7350cf --- /dev/null +++ b/traitsui/testing/qt4/implementation/tree_editor.py @@ -0,0 +1,121 @@ +from traitsui.qt4.tree_editor import SimpleEditor +from traitsui.testing.qt4 import helpers +from traitsui.testing import command, locator, registry_helper, query + + +class _SimpleEditorWithTreeNode: + """ Wrapper for (simple) TreeEditor and locator.TreeNode""" + + def __init__(self, editor, node): + self.editor = editor + self.node = node + + @classmethod + def from_location(cls, wrapper, location): + return cls( + editor=wrapper.editor, + node=location, + ) + + @classmethod + def register(cls, registry): + registry.register_location_solver( + target_class=SimpleEditor, + locator_class=locator.TreeNode, + solver=cls.from_location, + ) + registry.register( + target_class=cls, + interaction_class=command.MouseClick, + handler=lambda wrapper, _: wrapper.editor._mouse_click( + delay=wrapper.delay, + ), + ) + registry.register( + target_class=cls, + interaction_class=command.MouseDClick, + handler=lambda wrapper, _: wrapper.editor._mouse_dclick( + delay=wrapper.delay, + ), + ) + registry.register( + target_class=cls, + interaction_class=command.KeySequence, + handler=lambda wrapper, action: wrapper.editor._key_sequence( + sequence=action.sequence, + delay=wrapper.delay, + ), + ) + registry.register( + target_class=cls, + interaction_class=command.KeyClick, + handler=lambda wrapper, action: wrapper.editor._key_press( + key=action.key, + delay=wrapper.delay, + ), + ) + registry.register( + target_class=cls, + interaction_class=query.DisplayedText, + handler=lambda wrapper, _: ( + wrapper.editor._get_displayed_text() + ), + ) + + def _get_model_view_index(self): + tree_view = self.editor._tree + i_column = self.node.column + i_rows = iter(self.node.row) + item = tree_view.topLevelItem(next(i_rows)) + for i_row in i_rows: + item = item.child(i_row) + q_model_index = tree_view.indexFromItem(item, i_column) + return dict( + model=tree_view.model(), + view=tree_view, + index=q_model_index, + ) + + def _mouse_click(self, delay=0): + helpers.mouse_click_item_view( + **self._get_model_view_index(), + delay=delay, + ) + + def _mouse_dclick(self, delay=0): + helpers.mouse_dclick_item_view( + **self._get_model_view_index(), + delay=delay, + ) + + def _key_press(self, key, delay=0): + helpers.key_press_item_view( + **self._get_model_view_index(), + key=key, + delay=delay, + ) + + def _key_sequence(self, sequence, delay=0): + helpers.key_sequence_item_view( + **self._get_model_view_index(), + sequence=sequence, + delay=delay, + ) + + def _get_displayed_text(self): + return helpers.get_display_text_item_view( + **self._get_model_view_index(), + ) + + +def register(registry): + _SimpleEditorWithTreeNode.register(registry) + registry.register_location_solver( + target_class=SimpleEditor, + locator_class=locator.NestedUI, + solver=lambda wrapper, _: wrapper.editor._editor._node_ui, + ) + registry_helper.register_find_by_in_nested_ui( + registry=registry, + target_class=SimpleEditor, + ) \ No newline at end of file diff --git a/traitsui/testing/qt4/implementation/ui_base.py b/traitsui/testing/qt4/implementation/ui_base.py new file mode 100644 index 000000000..e03b572c0 --- /dev/null +++ b/traitsui/testing/qt4/implementation/ui_base.py @@ -0,0 +1,10 @@ +from traitsui.qt4.ui_base import ButtonEditor +from traitsui.testing import locator + + +def register(registry): + registry.register_location_solver( + target_class=ButtonEditor, + locator_class=locator.DefaultTarget, + solver=lambda wrapper, _: wrapper.editor.control, + ) diff --git a/traitsui/testing/qt4/tests/__init__.py b/traitsui/testing/qt4/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/traitsui/testing/qt4/tests/test_default_registry.py b/traitsui/testing/qt4/tests/test_default_registry.py new file mode 100644 index 000000000..9f4cef2c4 --- /dev/null +++ b/traitsui/testing/qt4/tests/test_default_registry.py @@ -0,0 +1,294 @@ +import unittest +from unittest import mock + +from pyface.gui import GUI +from traitsui.tests._tools import ( + is_qt, + requires_toolkit, + ToolkitName, +) +from traitsui.testing import command, locator, query +from traitsui.testing.exceptions import Disabled +from traitsui.testing.tests._tools import ( + create_interactor, +) +from traitsui.testing.ui_tester import UIWrapper + +try: + from pyface.qt import QtCore, QtGui + from traitsui.testing.qt4 import default_registry + from traitsui.testing.qt4 import helpers +except ImportError: + if is_qt(): + raise + + +@requires_toolkit([ToolkitName.qt]) +class TestInteractorAction(unittest.TestCase): + + def test_mouse_click(self): + button = QtGui.QPushButton() + click_slot = mock.Mock() + button.clicked.connect(click_slot) + + wrapper = wrapper = UIWrapper( + editor=button, + registries=[default_registry.get_default_registry()], + ) + + wrapper.perform(command.MouseClick()) + + self.assertEqual(click_slot.call_count, 1) + + def test_mouse_click_disabled(self): + button = QtGui.QPushButton() + button.setEnabled(False) + + click_slot = mock.Mock() + button.clicked.connect(click_slot) + + wrapper = wrapper = UIWrapper( + editor=button, + registries=[default_registry.get_default_registry()], + ) + + # when + # clicking won't fail, it just does not do anything. + # This is consistent with the actual UI. + wrapper.perform(command.MouseClick()) + + # then + self.assertEqual(click_slot.call_count, 0) + + def test_key_sequence(self): + textbox = QtGui.QLineEdit() + change_slot = mock.Mock() + textbox.textEdited.connect(change_slot) + wrapper = create_interactor(editor=textbox) + + # when + default_registry.key_sequence_qwidget( + wrapper, command.KeySequence("abc")) + + # then + self.assertEqual(textbox.text(), "abc") + # each keystroke fires a signal + self.assertEqual(change_slot.call_count, 3) + + def test_key_sequence_disabled(self): + textbox = QtGui.QLineEdit() + textbox.setEnabled(False) + wrapper = create_interactor(editor=textbox) + + # then + # this will fail, because one should not be allowed to set + # cursor on the widget to type anything + with self.assertRaises(Disabled): + default_registry.key_sequence_qwidget( + wrapper, command.KeySequence("abc")) + + def test_key_press(self): + textbox = QtGui.QLineEdit() + change_slot = mock.Mock() + textbox.editingFinished.connect(change_slot) + wrapper = create_interactor(editor=textbox) + + # sanity check + default_registry.key_sequence_qwidget( + wrapper, command.KeySequence("abc")) + self.assertEqual(change_slot.call_count, 0) + + default_registry.key_press_qwidget( + wrapper, command.KeyClick("Enter")) + self.assertEqual(change_slot.call_count, 1) + + def test_key_press_disabled(self): + textbox = QtGui.QLineEdit() + textbox.setEnabled(False) + change_slot = mock.Mock() + textbox.editingFinished.connect(change_slot) + wrapper = create_interactor(editor=textbox) + + with self.assertRaises(Disabled): + default_registry.key_press_qwidget( + wrapper, command.KeyClick("Enter")) + self.assertEqual(change_slot.call_count, 0) + + def test_mouse_click_combobox(self): + combobox = QtGui.QComboBox() + combobox.addItems(["a", "b", "c"]) + change_slot = mock.Mock() + combobox.currentIndexChanged.connect(change_slot) + + # when + helpers.mouse_click_combobox(combobox=combobox, index=1) + + # then + self.assertEqual(change_slot.call_count, 1) + (index, ), _ = change_slot.call_args_list[0] + self.assertEqual(index, 1) + + def test_mouse_click_combobox_index_out_of_range(self): + combobox = QtGui.QComboBox() + combobox.addItems(["a", "b", "c"]) + change_slot = mock.Mock() + combobox.currentIndexChanged.connect(change_slot) + + # when + with self.assertRaises(LookupError): + helpers.mouse_click_combobox(combobox=combobox, index=10) + + # then + self.assertEqual(change_slot.call_count, 0) + + def test_mouse_click_combobox_different_model(self): + # Test with a different item model + item_model = QtGui.QStringListModel() + combobox = QtGui.QComboBox() + combobox.setModel(item_model) + combobox.addItems(["a", "b", "c"]) + change_slot = mock.Mock() + combobox.currentIndexChanged.connect(change_slot) + + # when + helpers.mouse_click_combobox(combobox=combobox, index=1) + + # then + self.assertEqual(change_slot.call_count, 1) + self.assertEqual(combobox.currentIndex(), 1) + + +@requires_toolkit([ToolkitName.qt]) +class TestHelperIndexedLayout(unittest.TestCase): + + def test_mouse_click_list_layout(self): + widget = QtGui.QWidget() + layout = QtGui.QGridLayout(widget) + button0 = QtGui.QPushButton() + button0_clicked = mock.Mock() + button0.clicked.connect(button0_clicked) + button1 = QtGui.QPushButton() + button1_clicked = mock.Mock() + button1.clicked.connect(button1_clicked) + layout.addWidget(button0, 0, 0) + layout.addWidget(button1, 1, 0) + + # when + helpers.mouse_click_qlayout(layout, 1) + + # then + self.assertEqual(button0_clicked.call_count, 0) + self.assertEqual(button1_clicked.call_count, 1) + + +@requires_toolkit([ToolkitName.qt]) +class TestHelperIndexedItemView(unittest.TestCase): + + def setUp(self): + self.widget = QtGui.QListWidget() + self.items = ["a", "b", "c"] + self.widget.addItems(self.items) + + self.good_q_index = self.widget.model().index(1, 0) + self.bad_q_index = self.widget.model().index(10, 0) + + self.model = self.widget.model() + + def test_mouse_click_list_widget_item_view(self): + change_slot = mock.Mock() + self.widget.currentTextChanged.connect(change_slot) + + # when + helpers.mouse_click_item_view( + model=self.model, + view=self.widget, + index=self.good_q_index, + ) + + # then + self.assertEqual(change_slot.call_count, 1) + (text, ), _ = change_slot.call_args_list[0] + self.assertEqual(text, "b") + + def test_mouse_click_list_widget_item_view_out_of_range(self): + change_slot = mock.Mock() + self.widget.currentTextChanged.connect(change_slot) + + # when + with self.assertRaises(LookupError): + helpers.mouse_click_item_view( + model=self.model, + view=self.widget, + index=self.bad_q_index, + ) + + # then + self.assertEqual(change_slot.call_count, 0) + + def test_key_sequence_list_widget_item_view_disabled(self): + + # by default the item is not editable + with self.assertRaises(Disabled) as exception_context: + helpers.key_sequence_item_view( + model=self.model, + view=self.widget, + index=self.good_q_index, + sequence="a", + ) + self.assertEqual( + str(exception_context.exception), + "No editable widget for item at row 1 and column 0", + ) + + def test_key_press_list_widget_item_view_disabled(self): + + # by default the item is not editable + with self.assertRaises(Disabled) as exception_context: + helpers.key_press_item_view( + model=self.model, + view=self.widget, + index=self.good_q_index, + key="Enter", + ) + self.assertEqual( + str(exception_context.exception), + "No editable widget for item at row 1 and column 0", + ) + + def test_key_sequence_and_press_list_widget_item_view(self): + for index in range(self.widget.count()): + item = self.widget.item(index) + item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable) + + target_item = self.widget.item(1) + self.widget.setCurrentItem(target_item) + self.widget.editItem(target_item) + + # when + helpers.key_sequence_item_view( + model=self.model, + view=self.widget, + index=self.good_q_index, + sequence="\bd", + ) + helpers.key_press_item_view( + model=self.model, + view=self.widget, + index=self.good_q_index, + key="Enter", + ) + # this needs an extra kick. The tester will do this too. + GUI.process_events() + + # then + self.assertEqual(self.widget.item(1).text(), "d") + + def test_get_displayed_text_list_widget_item_view(self): + # when + actual = helpers.get_display_text_item_view( + model=self.model, + view=self.widget, + index=self.good_q_index, + ) + # then + self.assertEqual(actual, "b") diff --git a/traitsui/testing/query.py b/traitsui/testing/query.py new file mode 100644 index 000000000..93b09aad4 --- /dev/null +++ b/traitsui/testing/query.py @@ -0,0 +1,19 @@ +""" This module defines action objects that can be passed to +``UITester.perform`` where the actions represent 'queries'. + +Implementations for these actions are expected to return value(s), ideally +without incurring side-effects (though in some situation side effects might +also be expected in the GUI context.) +""" + + +class DisplayedText: + """ An object representing an action to obtain the displayed (echoed) + plain text. + + E.g. For a textbox using a password styling, the displayed text should + be a string of platform-dependent password mask characters. + + Implementations should return a str. + """ + pass diff --git a/traitsui/testing/registry_helper.py b/traitsui/testing/registry_helper.py new file mode 100644 index 000000000..a1d3b31f5 --- /dev/null +++ b/traitsui/testing/registry_helper.py @@ -0,0 +1,24 @@ +from traitsui.testing import locator + + +def find_by_id_in_nested_ui(wrapper, location): + new_interactor = wrapper.locate(locator.NestedUI()) + return new_interactor.find_by_id(location.id).editor + + +def find_by_name_in_nested_ui(wrapper, location): + new_interactor = wrapper.locate(locator.NestedUI()) + return new_interactor.find_by_name(location.name).editor + + +def register_find_by_in_nested_ui(registry, target_class): + registry.register_location_solver( + target_class=target_class, + locator_class=locator.TargetById, + solver=find_by_id_in_nested_ui, + ) + registry.register_location_solver( + target_class=target_class, + locator_class=locator.TargetByName, + solver=find_by_name_in_nested_ui, + ) 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/_tools.py b/traitsui/testing/tests/_tools.py new file mode 100644 index 000000000..5b781798f --- /dev/null +++ b/traitsui/testing/tests/_tools.py @@ -0,0 +1,23 @@ +from traitsui.testing.interactor_registry import InteractionRegistry +from traitsui.testing.ui_tester import command, UIWrapper + + +class FakeEditor: + def __init__(self, control): + self.control = control + + +def create_interactor(control=None, editor=None, locator_classes=None): + registry = InteractionRegistry() + + if locator_classes: + for locator_class in locator_classes: + registry.register_location_solver( + target_class=FakeEditor, + locator_class=locator_class, + solver=None, + ) + + if editor is None: + editor = FakeEditor(control=control) + return UIWrapper(editor, [registry]) diff --git a/traitsui/testing/tests/test_default_registry.py b/traitsui/testing/tests/test_default_registry.py new file mode 100644 index 000000000..b7dae7ec4 --- /dev/null +++ b/traitsui/testing/tests/test_default_registry.py @@ -0,0 +1,14 @@ +import unittest + +from traitsui.testing.default_registry import get_default_registries + + +class TestDefaultRegistry(unittest.TestCase): + + def test_load_default_registries(self): + registries = get_default_registries() + for registry in registries: + self.assertGreaterEqual( + len(registry.editor_to_action_to_handler), + 1, + ) diff --git a/traitsui/testing/tests/test_ui_tester.py b/traitsui/testing/tests/test_ui_tester.py new file mode 100644 index 000000000..f3d549dcd --- /dev/null +++ b/traitsui/testing/tests/test_ui_tester.py @@ -0,0 +1,466 @@ + +import unittest +from unittest import mock + +from traits.api import ( + Button, Instance, HasTraits, Int, Str, +) +from traitsui.api import ButtonEditor, InstanceEditor, Item, ModelView, View +from traitsui.testing.api import ( + command, Disabled, InteractionRegistry, locator, query, UITester, +) +from traitsui.testing.exceptions import ( + LocationNotSupported, +) +from traitsui.testing.ui_tester import ( + UIWrapper, +) +from traitsui.tests._tools import ( + is_qt, + is_wx, + requires_toolkit, + ToolkitName, +) +from traitsui.ui import UI + + +class Order(HasTraits): + + submit_button = Button() + + submit_n_events = Int() + + submit_label = Str("Submit") + + def _submit_button_fired(self): + self.submit_n_events += 1 + self.submit_label = ( + "Submit (ordered {} times)".format(self.submit_n_events) + ) + + +class Model(HasTraits): + + order = Instance(Order, ()) + + +class SimpleApplication(ModelView): + + model = Instance(Model) + + +def get_view_with_instance_editor(style, enabled_when=""): + """ Return a view for SimpleApplication using InstanceEditor with the + given style. + """ + return View( + Item( + "model.order", + id="model_order_id", + style=style, + editor=InstanceEditor( + view=View( + Item( + name="submit_button", + id="submit_button_id", + editor=ButtonEditor( + label_value="submit_label", + ), + enabled_when=enabled_when, + ), + ), + ), + ) + ) + + +class ManyMouseClick: + def __init__(self, n_times): + self.n_times = n_times + + +class GetEnableFlag: + pass + + +class BadAction: + pass + + +def raise_error(wrapper, action): + raise ZeroDivisionError() + + +if is_qt(): + from traitsui.qt4.button_editor import SimpleEditor as SimpleButtonEditor + + def click_n_times(wrapper, action): + for _ in range(action.n_times): + if not wrapper.editor.control.isEnabled(): + raise Disabled("Button is disabled.") + wrapper.editor.control.click() + + def is_enabled(wrapper, action): + return wrapper.editor.control.isEnabled() + + +if is_wx(): + from traitsui.wx.button_editor import SimpleEditor as SimpleButtonEditor + + def click_n_times(wrapper, action): # noqa: F811 + import wx + control = wrapper.editor.control + if not control.IsEnabled(): + raise Disabled("Button is disabled.") + event = wx.CommandEvent(wx.EVT_BUTTON.typeId, control.GetId()) + event.SetEventObject(control) + for _ in range(action.n_times): + wx.PostEvent(control, event) + + def is_enabled(wrapper, action): # noqa: F811 + return wrapper.editor.control.IsEnabled() + + +def get_local_registry(): + registry = InteractionRegistry() + registry.register( + target_class=SimpleButtonEditor, + interaction_class=ManyMouseClick, + handler=click_n_times, + ) + registry.register( + target_class=SimpleButtonEditor, + interaction_class=GetEnableFlag, + handler=is_enabled, + ) + registry.register( + target_class=SimpleButtonEditor, + interaction_class=BadAction, + handler=raise_error, + ) + return registry + + +@requires_toolkit([ToolkitName.qt, ToolkitName.wx]) +class TestUITesterSimulate(unittest.TestCase): + """ Test using additional interactors with a local registry. + """ + + def check_command_propagated_to_nested_ui(self, style): + # the default wrapper for the instance editor is used, and then + # the custom simulation on the button is used. + order = Order() + view = get_view_with_instance_editor(style) + app = SimpleApplication(model=Model(order=order)) + tester = UITester() + tester.add_registry(get_local_registry()) + with tester.create_ui(app, dict(view=view)) as ui: + tester\ + .find_by_name(ui, "order")\ + .find_by_name("submit_button")\ + .perform(ManyMouseClick(3)) + self.assertEqual(order.submit_n_events, 3) + + def test_command_custom_interactor_with_simple_instance_editor(self): + # Check command is propagated by the wrapper get_ui for + # simple instance editor. + self.check_command_propagated_to_nested_ui("simple") + + def test_command_custom_interactor_with_custom_instance_editor(self): + # Check command is propagated by the wrapper get_ui for + # custom instance editor. + self.check_command_propagated_to_nested_ui("custom") + + def check_query_propagated_to_nested_ui(self, style): + # the default wrapper for the instance editor is used, and then + # the custom simulation on the button is used. + order = Order() + view = get_view_with_instance_editor(style, enabled_when="False") + app = SimpleApplication(model=Model(order=order)) + tester = UITester() + tester.add_registry(get_local_registry()) + with tester.create_ui(app, dict(view=view)) as ui: + actual = tester\ + .find_by_name(ui, "order")\ + .find_by_name("submit_button")\ + .inspect(GetEnableFlag()) + self.assertIs(actual, False) + + def test_query_custom_interactor_with_simple_instance_editor(self): + # Check query is propagated by the wrapper get_ui for + # simple instance editor. + self.check_query_propagated_to_nested_ui("simple") + + def test_query_custom_interactor_with_custom_instance_editor(self): + # Check query is propagated by the wrapper get_ui for + # custom instance editor. + self.check_query_propagated_to_nested_ui("custom") + + def test_action_override_by_registry(self): + order = Order() + view = View(Item("submit_button")) + tester = UITester([get_local_registry()]) + + new_handler = mock.Mock() + registry = InteractionRegistry() + registry.register( + target_class=SimpleButtonEditor, + interaction_class=ManyMouseClick, + handler=new_handler, + ) + tester.add_registry(registry) + + with tester.create_ui(order, dict(view=view)) as ui: + tester.find_by_name(ui, "submit_button").perform( + ManyMouseClick(10) + ) + # The ManyMouseClick from get_local_registry is not used. + self.assertEqual(order.submit_n_events, 0) + # The ManyMouseClick from last register is used. + self.assertEqual(new_handler.call_count, 1) + + def test_action_override_by_registry_with_ancestor(self): + order = Order() + view = View(Item("submit_button")) + + lower_priority_handler = mock.Mock() + lower_priority_registry = InteractionRegistry() + lower_priority_registry.register( + target_class=SimpleButtonEditor, + interaction_class=ManyMouseClick, + handler=lower_priority_handler, + ) + higher_priority_handler = mock.Mock() + higher_priority_registry = InteractionRegistry() + higher_priority_registry.register( + target_class=UI, + interaction_class=ManyMouseClick, + handler=higher_priority_handler, + ) + tester = UITester([higher_priority_registry, lower_priority_registry]) + with tester.create_ui(order, dict(view=view)) as ui: + tester.find_by_name(ui, "submit_button").perform(ManyMouseClick(1)) + + # The one from the lower priority registry is called because the + # target_class is more specific. + self.assertEqual(lower_priority_handler.call_count, 1) + self.assertEqual(higher_priority_handler.call_count, 0) + + def test_backward_compatibility_when_new_location_introduced(self): + # Test code should not need to change when a new location solver is + # introduced. + order = Order() + view = View(Item("submit_button")) + + handler = mock.Mock() + registry = InteractionRegistry() + registry.register( + target_class=SimpleButtonEditor, + interaction_class=ManyMouseClick, + handler=handler, + ) + + # This new registry represents an additional logic in the future + # that should not break existing test code. + new_registry = InteractionRegistry() + new_registry.register_location_solver( + target_class=SimpleButtonEditor, + locator_class=locator.DefaultTarget, + solver=lambda wrapper, _: wrapper.editor.control, + ) + tester = UITester([new_registry, registry]) + with tester.create_ui(order, dict(view=view)) as ui: + tester.find_by_name(ui, "submit_button").perform(ManyMouseClick(1)) + + self.assertEqual(handler.call_count, 1) + + +@requires_toolkit([ToolkitName.qt, ToolkitName.wx]) +class TestUITesterCreateUI(unittest.TestCase): + """ Test UITester.create_ui + """ + + def test_ui_disposed(self): + tester = UITester([]) + order = Order() + view = View( + Item("submit_button", enabled_when="submit_n_events < 1"), + ) + with tester.create_ui(order, dict(view=view)) as ui: + order.submit_n_events = 2 + self.assertTrue(ui.destroyed) + + +class TestUITesterFind(unittest.TestCase): + """ Test find_by functionality.""" + + def test_create_ui_interactor(self): + tester = UITester(delay=1000) + with tester.create_ui(Order()) as ui: + wrapper = tester._get_ui_interactor(ui) + self.assertIs(wrapper.editor, ui) + self.assertEqual(wrapper.registries, tester._registries) + self.assertEqual(wrapper.delay, tester.delay) + + def test_ui_interactor_locate_by_name(self): + tester = UITester() + with tester.create_ui(Order()) as ui: + ui_interactor = tester._get_ui_interactor(ui) + new_interactor = ui_interactor.locate( + locator.TargetByName("submit_n_events") + ) + expected, = ui.get_editors("submit_n_events") + self.assertIs(new_interactor.editor, expected) + + def test_ui_interactor_locate_by_id(self): + tester = UITester() + view = View(Item(name="submit_n_events", id="pretty_id")) + with tester.create_ui(Order(), dict(view=view)) as ui: + ui_interactor = tester._get_ui_interactor(ui) + new_interactor = ui_interactor.locate( + locator.TargetById("pretty_id") + ) + expected, = ui.get_editors("submit_n_events") + self.assertIs(new_interactor.editor, expected) + + def test_no_editors_found(self): + # The view does not have "submit_n_events" + tester = UITester() + view = View(Item("submit_button")) + with tester.create_ui(Order(), dict(view=view)) as ui: + with self.assertRaises(ValueError) as exception_context: + tester.find_by_name(ui, "submit_n_events") + + self.assertIn( + "No editors can be found", str(exception_context.exception), + ) + + def test_multiple_editors_found(self): + # Repeated names not currently supported. + tester = UITester() + view = View(Item("submit_button"), Item("submit_button")) + with tester.create_ui(Order(), dict(view=view)) as ui: + with self.assertRaises(ValueError) as exception_context: + tester.find_by_name(ui, "submit_button") + + self.assertIn( + "Found multiple editors", str(exception_context.exception), + ) + + def test_error_propagated(self): + # The action raises an error. + # That error should be propagated. + order = Order() + view = get_view_with_instance_editor("simple", enabled_when="False") + app = SimpleApplication(model=Model(order=order)) + tester = UITester() + tester.add_registry(get_local_registry()) + with tester.create_ui(app, dict(view=view)) as ui: + order = tester.find_by_name(ui, "order") + submit_button = order.find_by_name("submit_button") + with self.assertRaises(ZeroDivisionError): + submit_button.perform(BadAction()) + + def test_find_by_id(self): + tester = UITester(delay=123) + item1 = Item("submit_button", id="item1") + item2 = Item("submit_button", id="item2") + view = View(item1, item2) + with tester.create_ui(Order(), dict(view=view)) as ui: + wrapper = tester.find_by_id(ui, "item2") + self.assertIs(wrapper.editor.item, item2) + self.assertEqual(wrapper.registries, tester._registries) + self.assertEqual(wrapper.delay, tester.delay) + + def test_find_by_id_multiple(self): + # The uniqueness is not enforced. The first one is returned. + tester = UITester() + item1 = Item("submit_button", id="item1") + item2 = Item("submit_button", id="item2") + item3 = Item("submit_button", id="item2") + view = View(item1, item2, item3) + with tester.create_ui(Order(), dict(view=view)) as ui: + wrapper = tester.find_by_id(ui, "item2") + self.assertIs(wrapper.editor.item, item2) + + def test_find_by_id_in_nested(self): + order = Order() + view = get_view_with_instance_editor(style="custom") + app = SimpleApplication(model=Model(order=order)) + tester = UITester() + with tester.create_ui(app, dict(view=view)) as ui: + order_interactor = tester.find_by_id(ui, "model_order_id") + submit_button = order_interactor.find_by_id("submit_button_id") + + self.assertEqual( + submit_button.editor.name, "submit_button") + self.assertEqual( + submit_button.editor.item.id, "submit_button_id") + self.assertEqual( + submit_button.editor.object, order) + + def test_find_by_name(self): + tester = UITester(delay=123) + item1 = Item("submit_button", id="item1") + item2 = Item("submit_n_events", id="item2") + view = View(item1, item2) + with tester.create_ui(Order(), dict(view=view)) as ui: + wrapper = tester.find_by_name(ui, "submit_n_events") + self.assertIs(wrapper.editor.item, item2) + self.assertEqual(wrapper.registries, tester._registries) + self.assertEqual(wrapper.delay, tester.delay) + + def test_find_by_name_in_nested(self): + order = Order() + view = get_view_with_instance_editor(style="custom") + app = SimpleApplication(model=Model(order=order)) + tester = UITester() + with tester.create_ui(app, dict(view=view)) as ui: + order_interactor = tester.find_by_name(ui, "order") + submit_button = order_interactor.find_by_name("submit_button") + + self.assertEqual( + submit_button.editor.name, "submit_button") + self.assertEqual( + submit_button.editor.object, order) + + +class FakeTarget: + pass + + +class TestUITesterLocate(unittest.TestCase): + + def setUp(self): + self.target_class = FakeTarget + self.registry = InteractionRegistry() + self.wrapper = UIWrapper( + editor=FakeTarget(), + registries=[self.registry], + ) + + def test_locate_with_resolved_target(self): + + target = (1, 2, 3) + + def resolve_target(wrapper, location): + return target + + self.registry.register_location_solver( + target_class=self.target_class, + locator_class=int, + solver=resolve_target, + ) + + new_location = self.wrapper.locate(0) + self.assertIs(new_location.editor, target) + + def test_locate_with_unknown_location(self): + + self.registry.register_location_solver( + target_class=self.target_class, + locator_class=int, + solver=mock.Mock(), + ) + with self.assertRaises(LocationNotSupported): + self.wrapper.locate("123") diff --git a/traitsui/testing/ui_tester.py b/traitsui/testing/ui_tester.py new file mode 100644 index 000000000..8d85e4ce6 --- /dev/null +++ b/traitsui/testing/ui_tester.py @@ -0,0 +1,422 @@ + +from contextlib import contextmanager + +from traitsui.tests._tools import ( + create_ui as _create_ui, + process_cascade_events, + reraise_exceptions, +) +from traitsui.testing import command +from traitsui.testing import locator +from traitsui.testing.exceptions import ( + ActionNotSupported, + LocationNotSupported, +) +from traitsui.testing.default_registry import ( + get_default_registries, + get_ui_registry, +) + + +@contextmanager +def event_processed(): + """ Context manager to ensure GUI events are processed upon entering + and exiting the context. + """ + with reraise_exceptions(): + process_cascade_events() + try: + yield + finally: + with reraise_exceptions(): + process_cascade_events() + + +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 TraitsUI application via GUI components. It + performs actions that imitate what a user may do or see in a GUI, + e.g. clicking a button, checking the text shown in a particular textbox. + + There are two main functions on the ``UITester``: + + - ``find_by_name``: Find an editor with the given extended name. + This locates the editor the developer would like to simulate user + interactions on. In order to use this function, the editor must be + uniquely identifiable by the given extended name. + - ``perform``: Perform the required user interaction. + This function makes sure that any pending GUI events are processed before + and after the action takes place. It also captures and reraises any + uncaught exceptions, as they may not otherwise have caused a test to + fail. + + Example:: + + from traitsui.testing.api import command, UITester + + class App(HasTraits): + + button = Button() + clicked = Bool(False) + + def _button_fired(self): + self.clicked = True + + app = App() + view = View(Item("button")) + tester = UITester() + with tester.create_ui(app, dict(view=view)) as ui: + tester.find_by_name(ui, "button").perform(command.MouseClick()) + assert app.clicked + + While user interactions are being simulated programmatically, this does not + fully replace manual testing by an actual human. In particular, platform + specific differences may not be taken into account. + + ``UITester`` can be used alongside other testing facilities such as + the ``GuiTestAssistant`` and ``ModalDialogTester``, to achieve other + testing objectives. + + ``UITester.perform`` accepts an action object which is defined in + ``traitsui.testing.api.command`` or ``traitsui.testing.api.query``. Note + that for a given editor, not all actions are supported, e.g. a button would + not support an action for entering some text. In those circumstances, an + error will be raised with suggestions on what actions are supported. + Custom actions can be defined via an instance of ``InteractionRegistry`` + provided to the ``UITester``. + + For example:: + + class MyAction: + pass + + class MyEditor(Editor): + ... + + def click(wrapper, action): + # ``wrapper`` is an instance of UIWrapper + wrapper.editor.control.click() + + my_registry = InteractionRegistry() + my_registry.register( + target_class=MyEditor, interaction_class=MyAction, handler=click, + ) + + def test_something(ui): + tester = UITester() + tester.add_registry(my_registry) + tester.find_by_name(ui, "some_name").perform(MyAction()) + + What the registry pattern replaces is the following alternative:: + + with capture_and_reraise_exceptions(): + GUI.process_events() + try: + UITester().find_by_name(ui, "some_name").editor.control.click() + finally: + GUI.process_events() + + The benefits of using a registry: + (1) Toolkit specific implementation are managed by the registry. + Test code using ``UITester`` can be kept toolkit agnostic. + (2) Event processing and exception handling capabilities can be reused. + """ + + def __init__(self, registries=None, delay=0): + """ Initialize a tester for testing GUI and traits interaction. + + Parameters + ---------- + registries : list of InteractionRegistry, optional + Registries of interactors for different editors, in the order + of decreasing priority. A shallow copy will be made. + Default is a list containing TraitsUI's registry only. + """ + + if registries is None: + self._registries = get_default_registries() + else: + self._registries = registries.copy() + self._registries.append(get_ui_registry()) + self.delay = delay + + def add_registry(self, registry): + """ Add a InteractionRegistry to the top of the registry list, i.e. + registry with the highest priority. + + Parameters + ---------- + registry : InteractionRegistry + """ + self._registries.insert(0, registry) + + 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, optional + Keyword arguments to be provided to ``HasTraits.edit_traits``. + Default is to call ``edit_traits`` with no additional keyword + arguments. + + Yields + ------ + ui : traitsui.ui.UI + """ + return _create_ui(object, ui_kwargs) + + def find_by_name(self, ui, name): + """ Find the UI editor with the given name and return an object for + simulating user interactions with the editor. The list of + ``InteractionRegistry`` in this tester is used for finding the editor + specified. + + 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 + ------- + wrapper : BaseUserInteractor + """ + return ( + self._get_ui_interactor(ui).locate(locator.TargetByName(name=name)) + ) + + def find_by_id(self, ui, id): + """ Find the UI editor with the given identifier and return an object + for simulating user interactions with the editor. The list of + ``InteractionRegistry`` in this tester is used for finding the editor + specified. + + Parameters + ---------- + ui : traitsui.ui.UI + The UI created, e.g. by ``create_ui``. + id : str + Id for finding an item in the UI. + + Returns + ------- + wrapper : BaseUserInteractor + """ + return self._get_ui_interactor(ui).locate(locator.TargetById(id=id)) + + def _get_ui_interactor(self, ui): + return UIWrapper( + editor=ui, + registries=self._registries, + delay=self.delay, + ) + + +class UIWrapper: + """ + An ``UIWrapper`` is responsible for dispatching specified user + interactions to toolkit specific implementations in order to test a GUI + component written using TraitsUI. + + Each instance of ``UIWrapper`` wraps an instance of Editor obtained + from a UI object. The editor should be in the state where the GUI has been + initialized and has not been disposed of such that it accepts user + commands. + + A ``UIWrapper`` finds toolkit specific implementations for simulating + user interactions from one or many ``InteractionRegistry`` objects. These + registries are typically provided via another public interface, e.g. + ``UITester``. + + Attributes + ---------- + editor : Editor + An instance of Editor. It is assumed to be in a state after the UI + has been initialized but before it is disposed of. + """ + + def __init__( + self, editor, registries, delay=0, ancestor=None): + self.editor = editor + self.registries = registries.copy() + self.delay = delay + self.ancestor = ancestor + + def perform(self, action): + """ Perform a user action that causes side effects. + + Parameters + ---------- + action : object + An action instance that defines the user action. + See ``traitsui.testing.command`` module for builtin + query objects. + e.g. ``traitsui.testing.command.MouseClick`` + """ + self._resolve( + lambda wrapper: wrapper._perform_or_inspect(action), + catches=ActionNotSupported, + ) + + def inspect(self, action): + """ Return a value or values for inspection. + + Parameters + ---------- + action : object + An action instance that defines the inspection. + See ``traitsui.testing.query`` module for builtin + query objects. + e.g. ``traitsui.testing.query.DisplayedText`` + """ + return self._resolve( + lambda wrapper: wrapper._perform_or_inspect(action), + catches=ActionNotSupported, + ) + + def locate(self, location): + """ Return a new wrapper for performing user actions on a specific + location specified. + + Implementations must validate the type of the location given. + However, it is optional for the implementation to resolve the given + location on the current UI at this stage. + + Parameters + ---------- + location : Location + """ + return self._resolve( + lambda wrapper: wrapper._new_from_location(location), + catches=LocationNotSupported, + ) + + def find_by_name(self, name): + """ Find the next target using a given name. + + Note that this is not a recursive search. + + Parameters + ---------- + name : str + A single name for retreiving the next target. + + Returns + ------- + wrapper : UIWrapper + """ + return self.locate(locator.TargetByName(name=name)) + + def find_by_id(self, id): + """ Find the next target using a unique identifier. + + Note that this is not a recursive search. + + Parameters + ---------- + id : any + An object for uniquely identifying target. + + Returns + ------- + wrapper : UIWrapper + """ + return self.locate(locator.TargetById(id=id)) + + def _resolve(self, function, catches): + """ Execute the given callable with this wrapper, if fails, try + again with the default target (if there is one). + + Parameters + ---------- + function : callable(UIWrapper) -> any + Function to resolve. + catches : Exception or tuple of Exception + Exceptions to catch and then retry with the default target. + + Returns + ------- + value : any + Returned value from the given function. + """ + try: + return function(self) + except catches as e: + try: + default = self._new_from_location(locator.DefaultTarget()) + except LocationNotSupported: + raise e + else: + return function(default) + + def _new_from_location(self, location): + """ Attempt to resolve the given location and return a new + UIWrapper. + + Parameters + ---------- + location : Location + + Raises + ------ + LocationNotSupported + If the given location is not supported. + """ + handler = self._resolve_location_solver(location) + return UIWrapper( + editor=handler(self, location), + registries=self.registries, + delay=self.delay, + ancestor=self, + ) + + def _resolve_location_solver(self, location): + supported = set() + for registry in self.registries: + try: + return registry.get_location_solver( + self.editor.__class__, + location.__class__, + ) + except LocationNotSupported as e: + supported |= set(e.supported) + + raise LocationNotSupported( + target_class=self.editor.__class__, + locator_class=location.__class__, + supported=list(supported), + ) + + def _resolve_handler(self, action): + interaction_class = action.__class__ + supported = set() + for registry in self.registries: + try: + return registry.get_handler( + target_class=self.editor.__class__, + interaction_class=interaction_class, + ) + except ActionNotSupported as e: + supported |= set(e.supported) + + raise ActionNotSupported( + target_class=self.editor.__class__, + interaction_class=action.__class__, + supported=list(supported), + ) + + def _perform_or_inspect(self, action): + """ Perform a user action or a user inspection. + """ + handler = self._resolve_handler(action) + with event_processed(): + return handler(self, action) diff --git a/traitsui/testing/wx/__init__.py b/traitsui/testing/wx/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/traitsui/testing/wx/default_registry.py b/traitsui/testing/wx/default_registry.py new file mode 100644 index 000000000..23a7d4860 --- /dev/null +++ b/traitsui/testing/wx/default_registry.py @@ -0,0 +1,108 @@ +from functools import partial, reduce + +import wx + +from traitsui.api import ( + ButtonEditor, + InstanceEditor, +) +from traitsui.wx.instance_editor import ( + SimpleEditor as SimpleInstanceEditor, + CustomEditor as CustomInstanceEditor, +) +from traitsui.wx.range_editor import ( + SimpleSliderEditor, + RangeTextEditor, +) +from traitsui.testing import command +from traitsui.testing import query +from traitsui.testing import locator +from traitsui.testing import registry_helper +from traitsui.testing.interactor_registry import InteractionRegistry +from traitsui.testing.wx import helpers +from traitsui.testing.wx.implementation import ( + button_editor, + range_editor, + text_editor, + ui_base, +) + + +def resolve_location_simple_editor(wrapper, _): + return wrapper.editor.edit_instance(None) + + +def resolve_location_custom_instance_editor(wrapper, _): + return wrapper.editor._ui + + +def get_default_registry(): + registry = get_generic_registry() + + # ButtonEditor + button_editor.register(registry) + + # InstanceEditor + registry.register_location_solver( + target_class=SimpleInstanceEditor, + locator_class=locator.DefaultTarget, + solver=resolve_location_simple_editor, + ) + registry.register_location_solver( + target_class=CustomInstanceEditor, + locator_class=locator.DefaultTarget, + solver=resolve_location_custom_instance_editor, + ) + + # RangeEditor + range_editor.register(registry) + + # TextEditor + text_editor.register(registry) + + ui_base.register(registry) + return registry + + +def get_generic_registry(): + registry = InteractionRegistry() + + registry.register( + target_class=wx.TextCtrl, + interaction_class=command.KeyClick, + handler=lambda wrapper, action: ( + helpers.key_press_text_ctrl( + control=wrapper.editor, + key=action.key, + delay=wrapper.delay, + ) + ), + ) + registry.register( + target_class=wx.TextCtrl, + interaction_class=command.KeySequence, + handler=lambda wrapper, action: ( + helpers.key_sequence_text_ctrl( + control=wrapper.editor, + sequence=action.sequence, + delay=wrapper.delay, + ) + ), + ) + registry.register( + target_class=wx.StaticText, + interaction_class=query.DisplayedText, + handler=lambda wrapper, action: ( + wrapper.editor.GetLabel() + ), + ) + registry.register( + target_class=wx.Button, + interaction_class=command.MouseClick, + handler=lambda wrapper, _: ( + helpers.mouse_click_button( + control=wrapper.editor, delay=wrapper.delay, + ) + ) + ) + return registry diff --git a/traitsui/testing/wx/helpers.py b/traitsui/testing/wx/helpers.py new file mode 100644 index 000000000..7f83a202b --- /dev/null +++ b/traitsui/testing/wx/helpers.py @@ -0,0 +1,55 @@ +import wx + + +def key_press_slider(slider, key, delay): + if key not in {"Up", "Down", "Right", "Left"}: + raise ValueError("Unexpected key.") + if not slider.HasFocus(): + slider.SetFocus() + value = slider.GetValue() + range_ = slider.GetMax() - slider.GetMin() + step = int(range_ / slider.GetLineSize()) + wx.MilliSleep(delay) + + if key in {"Up", "Right"}: + position = min(slider.GetMax(), value + step) + else: + position = max(0, value - step) + slider.SetValue(position) + event = wx.ScrollEvent( + wx.wxEVT_SCROLL_CHANGED, slider.GetId(), position + ) + wx.PostEvent(slider, event) + + +def key_press_text_ctrl(control, key, delay): + if key == "Enter": + if not control.HasFocus(): + control.SetFocus() + wx.MilliSleep(delay) + event = wx.CommandEvent(wx.EVT_TEXT_ENTER.typeId, control.GetId()) + control.ProcessEvent(event) + else: + raise ValueError("Only supported Enter key.") + + +def key_sequence_text_ctrl(control, sequence, delay): + if not control.HasFocus(): + control.SetFocus() + for char in sequence: + wx.MilliSleep(delay) + if char == "\b": + pos = control.GetInsertionPoint() + control.Remove(max(0, pos - 1), pos) + else: + control.AppendText(char) + + +def mouse_click_button(control, delay): + if not control.IsEnabled(): + return + wx.MilliSleep(delay) + click_event = wx.CommandEvent( + wx.wxEVT_COMMAND_BUTTON_CLICKED, control.GetId() + ) + control.ProcessEvent(click_event) diff --git a/traitsui/testing/wx/implementation/__init__.py b/traitsui/testing/wx/implementation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/traitsui/testing/wx/implementation/button_editor.py b/traitsui/testing/wx/implementation/button_editor.py new file mode 100644 index 000000000..814515598 --- /dev/null +++ b/traitsui/testing/wx/implementation/button_editor.py @@ -0,0 +1,11 @@ +from traitsui.wx.button_editor import SimpleEditor +from traitsui.testing import locator +from traitsui.testing.wx import helpers + + +def register(registry): + registry.register_location_solver( + target_class=SimpleEditor, + locator_class=locator.DefaultTarget, + solver=lambda wrapper, _: wrapper.editor.control, + ) diff --git a/traitsui/testing/wx/implementation/range_editor.py b/traitsui/testing/wx/implementation/range_editor.py new file mode 100644 index 000000000..4ff7a2497 --- /dev/null +++ b/traitsui/testing/wx/implementation/range_editor.py @@ -0,0 +1,55 @@ + +from traitsui.wx.range_editor import ( + SimpleSliderEditor, + RangeTextEditor, +) +from traitsui.testing.wx import helpers +from traitsui.testing import command, locator + + +class _SimpleSliderEditorWithSlider: + """ Wrapper for SimpleSliderEditor + locator.WidgetType.slider""" + + def __init__(self, editor): + self.editor = editor + + @classmethod + def register(cls, registry): + registry.register( + target_class=cls, + interaction_class=command.KeyClick, + handler=lambda wrapper, action: ( + wrapper.editor.key_press( + key=action.key, delay=wrapper.delay, + ) + ) + ) + + def key_press(self, key, delay=0): + helpers.key_press_slider( + slider=self.editor.control.slider, + key=key, + delay=delay, + ) + + +def resolve_location_simple_slider(wrapper, location): + if location == locator.WidgetType.slider: + return _SimpleSliderEditorWithSlider( + editor=wrapper.editor, + ) + if location == locator.WidgetType.textbox: + return wrapper.editor.control.text + + raise NotImplementedError() + + +def register(registry): + + registry.register_location_solver( + target_class=SimpleSliderEditor, + locator_class=locator.WidgetType, + solver=resolve_location_simple_slider, + ) + _SimpleSliderEditorWithSlider.register(registry) + diff --git a/traitsui/testing/wx/implementation/text_editor.py b/traitsui/testing/wx/implementation/text_editor.py new file mode 100644 index 000000000..c858e3f2b --- /dev/null +++ b/traitsui/testing/wx/implementation/text_editor.py @@ -0,0 +1,15 @@ +from traitsui.wx.text_editor import SimpleEditor, ReadonlyEditor +from traitsui.testing import locator + + +def register(registry): + registry.register_location_solver( + target_class=SimpleEditor, + locator_class=locator.DefaultTarget, + solver=lambda wrapper, _: wrapper.editor.control, + ) + registry.register_location_solver( + target_class=ReadonlyEditor, + locator_class=locator.DefaultTarget, + solver=lambda wrapper, _: wrapper.editor.control, + ) diff --git a/traitsui/testing/wx/implementation/ui_base.py b/traitsui/testing/wx/implementation/ui_base.py new file mode 100644 index 000000000..d0e174127 --- /dev/null +++ b/traitsui/testing/wx/implementation/ui_base.py @@ -0,0 +1,10 @@ +from traitsui.wx.ui_base import ButtonEditor +from traitsui.testing import locator + + +def register(registry): + registry.register_location_solver( + target_class=ButtonEditor, + locator_class=locator.DefaultTarget, + solver=lambda wrapper, _: wrapper.editor.control, + ) diff --git a/traitsui/testing/wx/tests/__init__.py b/traitsui/testing/wx/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/traitsui/testing/wx/tests/test_default_registry.py b/traitsui/testing/wx/tests/test_default_registry.py new file mode 100644 index 000000000..e6db55a53 --- /dev/null +++ b/traitsui/testing/wx/tests/test_default_registry.py @@ -0,0 +1,65 @@ +import unittest +from unittest import mock + +from pyface.api import GUI +from traitsui.tests._tools import ( + is_wx, + requires_toolkit, + ToolkitName, +) +from traitsui.testing import command +from traitsui.testing.ui_tester import UIWrapper +from traitsui.testing.tests._tools import ( + create_interactor, +) + +try: + import wx + from traitsui.testing.wx import default_registry +except ImportError: + if is_wx(): + raise + + +@requires_toolkit([ToolkitName.wx]) +class TestInteractorAction(unittest.TestCase): + + def setUp(self): + self.app = wx.App() + self.frame = wx.Frame(None) + self.frame.Show() + + def tearDown(self): + wx.CallAfter(self.app.ExitMainLoop) + self.app.MainLoop() + + def test_mouse_click(self): + handler = mock.Mock() + button = wx.Button(self.frame) + button.Bind(wx.EVT_BUTTON, handler) + wrapper = UIWrapper( + editor=button, + registries=[default_registry.get_default_registry()], + ) + + # when + wrapper.perform(command.MouseClick()) + + # then + self.assertEqual(handler.call_count, 1) + + def test_mouse_click_disabled_button(self): + handler = mock.Mock() + button = wx.Button(self.frame) + button.Bind(wx.EVT_BUTTON, handler) + button.Enable(False) + wrapper = UIWrapper( + editor=button, + registries=[default_registry.get_default_registry()], + ) + + # when + wrapper.perform(command.MouseClick()) + + # then + self.assertEqual(handler.call_count, 0) diff --git a/traitsui/tests/_tools.py b/traitsui/tests/_tools.py index 9597ed63c..10d4a76cd 100644 --- a/traitsui/tests/_tools.py +++ b/traitsui/tests/_tools.py @@ -19,7 +19,7 @@ import sys import traceback from contextlib import contextmanager -from unittest import skipIf, TestSuite +from unittest import skip, skipIf, TestSuite from pyface.api import GUI from pyface.toolkit import toolkit_object @@ -31,6 +31,11 @@ # ######### Testing tools +# Toolkit names as are used by ETSConfig +WX = "wx" +QT = "qt4" +NULL = "null" + _TRAITSUI_LOGGER = logging.getLogger("traitsui") @@ -151,6 +156,21 @@ def requires_toolkit(toolkits): is_mac_os = sys.platform.startswith("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/tests/editors/test_button_editor.py b/traitsui/tests/editors/test_button_editor.py index 8640abab5..38aa9b537 100644 --- a/traitsui/tests/editors/test_button_editor.py +++ b/traitsui/tests/editors/test_button_editor.py @@ -3,6 +3,7 @@ from pyface.gui import GUI from traits.api import Button, HasTraits, List, Str +from traits.testing.api import UnittestTools from traitsui.api import ButtonEditor, Item, UItem, View from traitsui.tests._tools import ( create_ui, @@ -13,6 +14,7 @@ reraise_exceptions, ToolkitName, ) +from traitsui.testing.api import command, InteractionRegistry, query, UITester class ButtonTextEdit(HasTraits): @@ -46,36 +48,45 @@ class ButtonTextEdit(HasTraits): ) -def get_button_text(button): - """ Return the button text given a button control """ - if is_wx(): - return button.GetLabel() - - elif is_qt(): - return button.text() +LOCAL_REGISTRY = InteractionRegistry() +if is_wx(): + from traitsui.wx.button_editor import CustomEditor, SimpleEditor + for target_class in [CustomEditor, SimpleEditor]: + LOCAL_REGISTRY.register( + target_class=target_class, + interaction_class=query.DisplayedText, + handler=lambda wrapper, _: wrapper.editor.control.GetLabel(), + ) +elif is_qt(): + from traitsui.qt4.button_editor import CustomEditor, SimpleEditor + for target_class in [CustomEditor, SimpleEditor]: + LOCAL_REGISTRY.register( + target_class=target_class, + interaction_class=query.DisplayedText, + handler=lambda wrapper, _: wrapper.editor.control.text(), + ) @requires_toolkit([ToolkitName.qt, ToolkitName.wx]) -class TestButtonEditor(unittest.TestCase): +class TestButtonEditor(unittest.TestCase, UnittestTools): def check_button_text_update(self, view): button_text_edit = ButtonTextEdit() - with reraise_exceptions(), \ - create_ui(button_text_edit, dict(view=view)) as ui: - - process_cascade_events() - editor, = ui.get_editors("play_button") - button = editor.control - - self.assertEqual(get_button_text(button), "I'm a play button") + tester = UITester() + tester.add_registry(LOCAL_REGISTRY) + with tester.create_ui(button_text_edit, dict(view=view)) as ui: + button = tester.find_by_name(ui, "play_button") + actual = button.inspect(query.DisplayedText()) + self.assertEqual(actual, "I'm a play button") button_text_edit.play_button_label = "New Label" - self.assertEqual(get_button_text(button), "New Label") + actual = button.inspect(query.DisplayedText()) + self.assertEqual(actual, "New Label") def test_styles(self): # simple smoke test of buttons button_text_edit = ButtonTextEdit() - with reraise_exceptions(), create_ui(button_text_edit): + with UITester().create_ui(button_text_edit): pass def test_simple_button_editor(self): @@ -84,6 +95,23 @@ def test_simple_button_editor(self): def test_custom_button_editor(self): self.check_button_text_update(custom_view) + def check_button_fired_event(self, view): + button_text_edit = ButtonTextEdit() + + tester = UITester() + with tester.create_ui(button_text_edit, dict(view=view)) as ui: + button = tester.find_by_name(ui, "play_button") + + with self.assertTraitChanges( + button_text_edit, "play_button", count=1): + button.perform(command.MouseClick()) + + def test_simple_button_editor_clicked(self): + self.check_button_fired_event(simple_view) + + def test_custom_button_editor_clicked(self): + self.check_button_fired_event(custom_view) + @requires_toolkit([ToolkitName.qt]) class TestButtonEditorValuesTrait(unittest.TestCase): @@ -106,7 +134,7 @@ def check_editor_values_trait_init_and_dispose(self, style): instance = ButtonTextEdit(values=["Item1", "Item2"]) view = self.get_view(style=style) with reraise_exceptions(): - with create_ui(instance, dict(view=view)): + with UITester().create_ui(instance, dict(view=view)): pass # It is okay to mutate trait after the GUI is disposed. diff --git a/traitsui/tests/editors/test_check_list_editor.py b/traitsui/tests/editors/test_check_list_editor.py index fc5d2bf4f..7fb4ad5d9 100644 --- a/traitsui/tests/editors/test_check_list_editor.py +++ b/traitsui/tests/editors/test_check_list_editor.py @@ -13,6 +13,7 @@ reraise_exceptions, ToolkitName, ) +from traitsui.testing.api import command, locator, UITester class ListModel(HasTraits): @@ -443,19 +444,16 @@ def test_custom_check_list_editor_button_update(self): def test_custom_check_list_editor_click(self): list_edit = ListModel() - with reraise_exceptions(), \ - self.setup_gui(list_edit, get_view("custom")) as editor: - + tester = UITester() + with tester.create_ui(list_edit, dict(view=get_view("custom"))) as ui: self.assertEqual(list_edit.value, []) - click_checkbox_button(editor.control, 1) - process_cascade_events() - + check_list = tester.find_by_name(ui, "value") + item_1 = check_list.locate(locator.Index(1)) + item_1.perform(command.MouseClick()) self.assertEqual(list_edit.value, ["two"]) - click_checkbox_button(editor.control, 1) - process_cascade_events() - + item_1.perform(command.MouseClick()) self.assertEqual(list_edit.value, []) def test_custom_check_list_editor_click_initial_value(self): diff --git a/traitsui/tests/editors/test_enum_editor.py b/traitsui/tests/editors/test_enum_editor.py index 09d37196d..17be56625 100644 --- a/traitsui/tests/editors/test_enum_editor.py +++ b/traitsui/tests/editors/test_enum_editor.py @@ -14,7 +14,7 @@ reraise_exceptions, ToolkitName, ) - +from traitsui.testing.api import command, locator, UITester is_windows = platform.system() == "Windows" @@ -318,15 +318,12 @@ def check_enum_object_update(self, view): def check_enum_index_update(self, view): enum_edit = EnumModel() + tester = UITester() - with reraise_exceptions(), \ - self.setup_gui(enum_edit, view) as editor: - + with tester.create_ui(enum_edit, dict(view=view)) as ui: self.assertEqual(enum_edit.value, "one") - - set_combobox_index(editor.control, 1) - process_cascade_events() - + combobox = tester.find_by_name(ui, "value") + combobox.locate(locator.Index(1)).perform(command.MouseClick()) self.assertEqual(enum_edit.value, "two") def check_enum_text_bad_update(self, view): @@ -439,16 +436,14 @@ def test_radio_enum_editor_button_update(self): def test_radio_enum_editor_pick(self): enum_edit = EnumModel() - - with reraise_exceptions(), \ - self.setup_gui(enum_edit, get_view("custom")) as editor: - + tester = UITester() + with tester.create_ui(enum_edit, dict(view=get_view("custom"))) as ui: self.assertEqual(enum_edit.value, "one") - # The layout is: one, three, four \n two - click_radio_button(editor.control, 3) - process_cascade_events() + radio_editor = tester.find_by_name(ui, "value") + radio_editor.locate(locator.Index(3)).perform(command.MouseClick()) + # The layout is: one, three, four \n two self.assertEqual(enum_edit.value, "two") @@ -478,13 +473,12 @@ def check_enum_text_update(self, view): def check_enum_index_update(self, view): enum_edit = EnumModel() - with reraise_exceptions(), \ - self.setup_gui(enum_edit, view) as editor: - + tester = UITester() + with tester.create_ui(enum_edit, dict(view=view)) as ui: self.assertEqual(enum_edit.value, "one") - set_list_widget_selected_index(editor.control, 1) - process_cascade_events() + combobox = tester.find_by_name(ui, "value") + combobox.locate(locator.Index(1)).perform(command.MouseClick()) self.assertEqual(enum_edit.value, "two") diff --git a/traitsui/tests/editors/test_list_editor.py b/traitsui/tests/editors/test_list_editor.py new file mode 100644 index 000000000..25ad06a0b --- /dev/null +++ b/traitsui/tests/editors/test_list_editor.py @@ -0,0 +1,67 @@ +import unittest + +from traits.api import HasTraits, Instance, List, Str +from traitsui.api import ListEditor, Item, View +from traitsui.testing.api import command, locator, query, UITester + + +class Person(HasTraits): + + name = Str() + + +class Phonebook(HasTraits): + people = List(Instance(Person)) + + +notebook_view = View( + Item( + "people", + style="custom", + editor=ListEditor(use_notebook=True), + ) +) + + +class TestNoteListEditor(unittest.TestCase): + + def test_modify_person_name(self): + person1 = Person() + person2 = Person() + phonebook = Phonebook( + people=[person1, person2], + ) + tester = UITester() + with tester.create_ui(phonebook, dict(view=notebook_view)) as ui: + list_ = tester.find_by_name(ui, "people") + list_.locate(locator.Index(1)).perform(command.MouseClick()) + + name_field = list_.locate(locator.Index(1)).find_by_name("name") + name_field.perform(command.KeySequence("Pete")) + + self.assertEqual(person2.name, "Pete") + + def test_get_person_name(self): + person1 = Person() + person2 = Person(name="Mary") + phonebook = Phonebook( + people=[person1, person2], + ) + tester = UITester() + with tester.create_ui(phonebook, dict(view=notebook_view)) as ui: + list_ = tester.find_by_name(ui, "people") + list_.locate(locator.Index(1)).perform(command.MouseClick()) + name_field = list_.locate(locator.Index(1)).find_by_name("name") + actual = name_field.inspect(query.DisplayedText()) + self.assertEqual(actual, "Mary") + + def test_index_out_of_bound(self): + phonebook = Phonebook( + people=[], + ) + tester = UITester() + with tester.create_ui(phonebook, dict(view=notebook_view)) as ui: + with self.assertRaises(IndexError): + tester.find_by_name(ui, "people").\ + locate(locator.Index(0)).\ + perform(command.MouseClick()) diff --git a/traitsui/tests/editors/test_range_editor_text.py b/traitsui/tests/editors/test_range_editor_text.py index f9a3fa101..65e7c723e 100644 --- a/traitsui/tests/editors/test_range_editor_text.py +++ b/traitsui/tests/editors/test_range_editor_text.py @@ -19,12 +19,14 @@ A RangeEditor in mode 'text' for an Int allows values out of range. """ import unittest +from unittest import mock from traits.has_traits import HasTraits from traits.trait_types import Float, Int from traitsui.item import Item from traitsui.view import View from traitsui.editors.range_editor import RangeEditor +from traitsui.testing.api import command, locator, UITester from traitsui.tests._tools import ( create_ui, @@ -55,7 +57,9 @@ class FloatWithRangeEditor(HasTraits): number = Float(5.0) traits_view = View( - Item("number", editor=RangeEditor(low=0.0, high=12.0)), buttons=["OK"] + Item("number", editor=RangeEditor(low=0.0, high=12.0)), + height=100, + buttons=["OK"], ) @@ -68,13 +72,12 @@ def test_wx_text_editing(self): # (tests a bug where this fails with an AttributeError) num = NumberWithRangeEditor() - with reraise_exceptions(), create_ui(num) as ui: - - # the following is equivalent to setting the text in the text - # control, then pressing OK - - textctrl = ui.control.FindWindowByName("text") - textctrl.SetValue("1") + tester = UITester() + with tester.create_ui(num) as ui, \ + mock.patch("wx.MessageDialog.ShowModal"): + text = tester.find_by_name(ui, "number") + text.perform(command.KeySequence("\b\b\b\b1")) + text.perform(command.KeyClick("Enter")) # the number traits should be between 3 and 8 self.assertTrue(3 <= num.number <= 8) @@ -83,17 +86,14 @@ def test_wx_text_editing(self): def test_avoid_slider_feedback(self): # behavior: when editing the text box part of a range editor, the value # should not be adjusted by the slider part of the range editor - from pyface import qt - num = FloatWithRangeEditor() - with reraise_exceptions(), create_ui(num) as ui: - - # the following is equivalent to setting the text in the text - # control, then pressing OK - lineedit = ui.control.findChild(qt.QtGui.QLineEdit) - lineedit.setFocus() - lineedit.setText("4") - lineedit.editingFinished.emit() + tester = UITester() + with tester.create_ui(num) as ui: + + text = tester.find_by_name(ui, "number")\ + .locate(locator.WidgetType.textbox) + text.perform(command.KeySequence("\b\b\b\b4")) + text.perform(command.KeyClick("Enter")) # the number trait should be 4 extactly self.assertEqual(num.number, 4.0) diff --git a/traitsui/tests/editors/test_table_editor.py b/traitsui/tests/editors/test_table_editor.py index cace645e9..6b73c737e 100644 --- a/traitsui/tests/editors/test_table_editor.py +++ b/traitsui/tests/editors/test_table_editor.py @@ -13,6 +13,14 @@ reraise_exceptions, ToolkitName, ) +from traitsui.testing.api import ( + Cell, + DisplayedText, + KeySequence, + KeyClick, + MouseClick, + UITester, +) class ListItem(HasTraits): @@ -470,6 +478,105 @@ def test_table_editor_select_cell(self): self.assertEqual(selected, (object_list.values[5], "value")) + @requires_toolkit([ToolkitName.qt, ToolkitName.wx]) + def test_table_editor_select_row_index_with_tester(self): + object_list = ObjectListWithSelection( + values=[ListItem(value=str(i ** 2)) for i in range(10)] + ) + view = View( + Item( + "values", + show_label=False, + editor=TableEditor( + sortable=False, # switch off sorting by first column + columns=[ + ObjectColumn(name="value"), + ObjectColumn(name="other_value"), + ], + selection_mode="row", + selected="selected", + ), + ), + ) + tester = UITester() + with tester.create_ui(object_list, dict(view=view)) as ui: + wrapper = tester.find_by_name(ui, "values") + + wrapper.locate(Cell(5, 0)).perform(MouseClick()) + self.assertEqual(object_list.selected.value, str(5 ** 2)) + + wrapper.locate(Cell(6, 0)).perform(MouseClick()) + self.assertEqual(object_list.selected.value, str(6 ** 2)) + + @requires_toolkit([ToolkitName.qt, ToolkitName.wx]) + def test_table_editor_modify_cell_with_tester(self): + object_list = ObjectListWithSelection( + values=[ListItem(value=str(i ** 2)) for i in range(10)] + ) + view = View( + Item( + "values", + show_label=False, + editor=TableEditor( + sortable=False, # switch off sorting by first column + columns=[ + ObjectColumn(name="value"), + ObjectColumn(name="other_value"), + ], + selection_mode="row", + selected="selected", + ), + ), + ) + tester = UITester() + with tester.create_ui(object_list, dict(view=view)) as ui: + wrapper = tester.find_by_name(ui, "values").locate(Cell(5, 0)) + wrapper.perform(MouseClick()) # activate edit mode + wrapper.perform(KeySequence("abc")) + self.assertEqual(object_list.selected.value, "abc") + + # second column refers to an Int type + original = object_list.selected.other_value + wrapper = tester.find_by_name(ui, "values").locate(Cell(5, 1)) + wrapper.perform(MouseClick()) + wrapper.perform(KeySequence("abc")) # invalid + self.assertEqual(object_list.selected.other_value, original) + + wrapper.perform(KeySequence("\b\b\b12")) # now ok + self.assertEqual(object_list.selected.other_value, 12) + + @requires_toolkit([ToolkitName.qt, ToolkitName.wx]) + def test_table_editor_check_display_with_tester(self): + object_list = ObjectListWithSelection( + values=[ListItem(other_value=0)] + ) + tester = UITester() + with tester.create_ui(object_list, dict(view=select_row_view)) as ui: + wrapper = tester.find_by_name(ui, "values").locate(Cell(0, 1)) + + actual = wrapper.inspect(DisplayedText()) + self.assertEqual(actual, "0") + + object_list.values[0].other_value = 123 + + actual = wrapper.inspect(DisplayedText()) + self.assertEqual(actual, "123") + + @requires_toolkit([ToolkitName.qt]) + def test_table_editor_escape_retain_edit(self): + object_list = ObjectListWithSelection( + values=[ListItem(other_value=0)] + ) + tester = UITester() + with tester.create_ui(object_list, dict(view=select_row_view)) as ui: + cell = tester.find_by_name(ui, "values").locate(Cell(0, 1)) + + cell.perform(MouseClick()) + cell.perform(KeySequence("123")) + cell.perform(KeyClick("Esc")) # exit edit mode, did not revert + + self.assertEqual(object_list.values[0].other_value, 123) + @requires_toolkit([ToolkitName.qt, ToolkitName.wx]) def test_table_editor_select_cells(self): object_list = ObjectListWithSelection( diff --git a/traitsui/tests/editors/test_text_editor.py b/traitsui/tests/editors/test_text_editor.py index 92d217c07..4aedb580f 100644 --- a/traitsui/tests/editors/test_text_editor.py +++ b/traitsui/tests/editors/test_text_editor.py @@ -18,8 +18,18 @@ from traits.api import ( HasTraits, Str, + pop_exception_handler, + push_exception_handler, ) +from traits.testing.api import UnittestTools from traitsui.api import TextEditor, View, Item +from traitsui.testing.api import ( + DisplayedText, + KeyClick, + KeySequence, + InteractionRegistry, + UITester, +) from traitsui.tests._tools import ( create_ui, GuiTestAssistant, @@ -54,55 +64,10 @@ def get_view(style, auto_set): ) -def get_text(editor): - """ Return the text from the widget for checking. - """ - if is_qt(): - return editor.control.text() - else: - raise unittest.SkipTest("Not implemented for the current toolkit.") - - -def set_text(editor, text): - """ Imitate user changing the text on the text box to a new value. Note - that this is equivalent to "clear and insert", which excludes confirmation - via pressing a return key or causing the widget to lose focus. - """ - - if is_qt(): - from pyface.qt import QtGui - if editor.base_style == QtGui.QLineEdit: - editor.control.clear() - editor.control.insert(text) - editor.control.textEdited.emit(text) - else: - editor.control.setText(text) - editor.control.textChanged.emit() - else: - raise unittest.SkipTest("Not implemented for the current toolkit.") - - -def key_press_return(editor): - """ Imitate user pressing the return key. - """ - if is_qt(): - from pyface.qt import QtGui - - # ideally we should fire keyPressEvent, but the editor does not - # bind to this event. Pressing return key will fire editingFinished - # event on a QLineEdit - if editor.base_style == QtGui.QLineEdit: - editor.control.editingFinished.emit() - else: - editor.control.append("") - else: - raise unittest.SkipTest("Not implemented for the current toolkit.") - - # Skips tests if the backend is not either qt4 or qt5 @requires_toolkit([ToolkitName.qt]) @unittest.skipIf(no_gui_test_assistant, "No GuiTestAssistant") -class TestTextEditorQt(GuiTestAssistant, unittest.TestCase): +class TestTextEditorQt(GuiTestAssistant, UnittestTools, unittest.TestCase): """ Test on TextEditor with Qt backend.""" def test_text_editor_placeholder_text(self): @@ -164,17 +129,21 @@ def test_text_editor_custom_style_placeholder(self): # We should be able to run this test case against wx. # Not running them now to avoid test interaction. See enthought/traitsui#752 @requires_toolkit([ToolkitName.qt]) -class TestTextEditor(unittest.TestCase): +class TestTextEditor(unittest.TestCase, UnittestTools): """ Tests that can be run with any toolkit as long as there is an implementation for simulating user interactions. """ + def setUp(self): + push_exception_handler(reraise_exceptions=True) + self.addCleanup(pop_exception_handler) + def check_editor_init_and_dispose(self, style, auto_set): # Smoke test to test setup and tear down of an editor. foo = Foo() view = get_view(style=style, auto_set=auto_set) - with reraise_exceptions(), \ - create_ui(foo, dict(view=view)): + tester = UITester() + with tester.create_ui(foo, dict(view=view)): pass def test_simple_editor_init_and_dispose(self): @@ -196,28 +165,21 @@ def test_custom_editor_init_and_dispose_no_auto_set(self): def test_simple_auto_set_update_text(self): foo = Foo() view = get_view(style="simple", auto_set=True) - with reraise_exceptions(), \ - create_ui(foo, dict(view=view)) as ui: - editor, = ui.get_editors("name") - set_text(editor, "NEW") - process_cascade_events() - + tester = UITester() + with tester.create_ui(foo, dict(view=view)) as ui: + with self.assertTraitChanges(foo, "name", count=3): + tester.find_by_name(ui, "name").perform(KeySequence("NEW")) self.assertEqual(foo.name, "NEW") def test_simple_auto_set_false_do_not_update(self): foo = Foo(name="") view = get_view(style="simple", auto_set=False) - with reraise_exceptions(), \ - create_ui(foo, dict(view=view)) as ui: - editor, = ui.get_editors("name") - - set_text(editor, "NEW") - process_cascade_events() - + tester = UITester() + with tester.create_ui(foo, dict(view=view)) as ui: + tester.find_by_name(ui, "name").perform(KeySequence("NEW")) self.assertEqual(foo.name, "") - key_press_return(editor) - process_cascade_events() + tester.find_by_name(ui, "name").perform(KeyClick("Enter")) self.assertEqual(foo.name, "NEW") @@ -225,29 +187,19 @@ def test_custom_auto_set_true_update_text(self): # the auto_set flag is disregard for custom editor. foo = Foo() view = get_view(auto_set=True, style="custom") - with reraise_exceptions(), \ - create_ui(foo, dict(view=view)) as ui: - editor, = ui.get_editors("name") - - set_text(editor, "NEW") - process_cascade_events() - + tester = UITester() + with tester.create_ui(foo, dict(view=view)) as ui: + tester.find_by_name(ui, "name").perform(KeySequence("NEW")) self.assertEqual(foo.name, "NEW") def test_custom_auto_set_false_update_text(self): # the auto_set flag is disregard for custom editor. foo = Foo() view = get_view(auto_set=False, style="custom") - with reraise_exceptions(), \ - create_ui(foo, dict(view=view)) as ui: - editor, = ui.get_editors("name") - - set_text(editor, "NEW") - process_cascade_events() - - key_press_return(editor) - process_cascade_events() - + tester = UITester() + with tester.create_ui(foo, dict(view=view)) as ui: + tester.find_by_name(ui, "name").perform(KeySequence("NEW")) + tester.find_by_name(ui, "name").perform(KeyClick("Enter")) self.assertEqual(foo.name, "NEW\n") @unittest.skipUnless( @@ -264,9 +216,26 @@ def test_format_func_used(self): Item("name", format_func=lambda s: s.upper()), Item("nickname"), ) - with reraise_exceptions(), \ - create_ui(foo, dict(view=view)) as ui: - name_editor, = ui.get_editors("name") - nickname_editor, = ui.get_editors("nickname") - self.assertEqual(get_text(name_editor), "WILLIAM") - self.assertEqual(get_text(nickname_editor), "bill") + tester = UITester() + with tester.create_ui(foo, dict(view=view)) as ui: + display_name = ( + tester.find_by_name(ui, "name").inspect(DisplayedText()) + ) + display_nickname = ( + tester.find_by_name(ui, "nickname").inspect(DisplayedText()) + ) + self.assertEqual(display_name, "WILLIAM") + self.assertEqual(display_nickname, "bill") + + def test_password_style(self): + foo = Foo(name="william") + view = View( + Item("name", editor=TextEditor(password=True)) + ) + tester = UITester() + with tester.create_ui(foo, dict(view=view)) as ui: + actual = tester.find_by_name(ui, "name").inspect(DisplayedText()) + # password mask character is platform dependent + self.assertEqual(len(actual), len(foo.name)) + self.assertNotEqual(actual, foo.name) + self.assertEqual(len(set(actual)), 1) diff --git a/traitsui/wx/instance_editor.py b/traitsui/wx/instance_editor.py index 282636a14..f9c2cffd5 100644 --- a/traitsui/wx/instance_editor.py +++ b/traitsui/wx/instance_editor.py @@ -527,6 +527,7 @@ def edit_instance(self, event): # have its own: if ui.history is None: ui.history = self.ui.history + return ui def resynch_editor(self): """ Resynchronizes the contents of the editor when the object trait