diff --git a/traitsui/qt4/range_editor.py b/traitsui/qt4/range_editor.py index 7f378d4f5..e5e632054 100644 --- a/traitsui/qt4/range_editor.py +++ b/traitsui/qt4/range_editor.py @@ -177,7 +177,8 @@ def update_object_on_scroll(self, pos): def update_object_on_enter(self): """ Handles the user pressing the Enter key in the text field. """ - # it is possible we get the event after the control has gone away + # It is possible the event is processed after the control is removed + # from the editor if self.control is None: return @@ -449,6 +450,10 @@ def update_object_on_scroll(self, pos): def update_object_on_enter(self): """ Handles the user pressing the Enter key in the text field. """ + # It is possible the event is processed after the control is removed + # from the editor + if self.control is None: + return try: self.value = eval(str(self.control.text.text()).strip()) except TraitError as excp: diff --git a/traitsui/testing/tester/locator.py b/traitsui/testing/tester/locator.py index d4e0b36b1..dcaece195 100644 --- a/traitsui/testing/tester/locator.py +++ b/traitsui/testing/tester/locator.py @@ -17,6 +17,8 @@ applied. """ +import enum + class NestedUI: """ A locator for locating a nested ``traitsui.ui.UI`` object assuming @@ -35,3 +37,15 @@ class TargetByName: """ def __init__(self, name): self.name = name + + +class WidgetType(enum.Enum): + """ A locator for locating nested widgets within a UI. Many editors will + contain many sub-widgets (e.g. a textbox, slider, tabs, buttons, etc.). + + For example when working with a range editor, one could call + ``tester.find_by_name(ui, "ranged_number").locate(locator.WidgetType.textbox)`` + """ + + # A textbox within a UI + textbox = "textbox" diff --git a/traitsui/testing/tester/qt4/common_ui_targets.py b/traitsui/testing/tester/qt4/common_ui_targets.py new file mode 100644 index 000000000..211b4b658 --- /dev/null +++ b/traitsui/testing/tester/qt4/common_ui_targets.py @@ -0,0 +1,65 @@ +# Copyright (c) 2005-2020, Enthought, Inc. +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only +# under the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +# + +""" This module contains targets for UIWrapper so that the logic related to +them can be reused. All handlers and solvers for these objects are +registered to the default registry via the register class methods. To use the +logic in these objects, one simply needs to register a solver with their +target_class of choice to one of these as the locator_class. For an example, +see the implementation of range_editor. +""" + +from traitsui.testing.tester import command, query +from traitsui.testing.tester.qt4 import helpers + + +class LocatedTextbox: + """ Wrapper class for a located Textbox in Qt. + + Parameters + ---------- + textbox : Instance of QtGui.QLineEdit + """ + + def __init__(self, textbox): + self.textbox = textbox + + @classmethod + def register(cls, registry): + """ Class method to register interactions on a LocatedTextbox for the + given registry. + + If there are any conflicts, an error will occur. + + Parameters + ---------- + registry : TargetRegistry + The registry being registered to. + """ + handlers = [ + (command.KeySequence, + (lambda wrapper, interaction: helpers.key_sequence_qwidget( + wrapper.target.textbox, interaction, wrapper.delay))), + (command.KeyClick, + (lambda wrapper, interaction: helpers.key_click_qwidget( + wrapper.target.textbox, interaction, wrapper.delay))), + (command.MouseClick, + (lambda wrapper, _: helpers.mouse_click_qwidget( + wrapper.target.textbox, wrapper.delay))), + (query.DisplayedText, + lambda wrapper, _: wrapper.target.textbox.displayText()), + ] + for interaction_class, handler in handlers: + registry.register_handler( + target_class=cls, + interaction_class=interaction_class, + handler=handler, + ) diff --git a/traitsui/testing/tester/qt4/default_registry.py b/traitsui/testing/tester/qt4/default_registry.py index 9084a3bcd..689d02ebf 100644 --- a/traitsui/testing/tester/qt4/default_registry.py +++ b/traitsui/testing/tester/qt4/default_registry.py @@ -10,8 +10,10 @@ # from traitsui.testing.tester.registry import TargetRegistry +from traitsui.testing.tester.qt4 import common_ui_targets from traitsui.testing.tester.qt4.implementation import ( button_editor, + range_editor, text_editor, ) @@ -27,10 +29,15 @@ def get_default_registry(): """ registry = TargetRegistry() + common_ui_targets.LocatedTextbox.register(registry) + # ButtonEditor button_editor.register(registry) # TextEditor text_editor.register(registry) + # RangeEditor + range_editor.register(registry) + return registry diff --git a/traitsui/testing/tester/qt4/implementation/range_editor.py b/traitsui/testing/tester/qt4/implementation/range_editor.py new file mode 100644 index 000000000..8554943d9 --- /dev/null +++ b/traitsui/testing/tester/qt4/implementation/range_editor.py @@ -0,0 +1,96 @@ +# Copyright (c) 2005-2020, Enthought, Inc. +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only +# under the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +# + +from traitsui.qt4.range_editor import ( + LargeRangeSliderEditor, + LogRangeSliderEditor, + RangeTextEditor, + SimpleSliderEditor, +) + +from traitsui.testing.tester import locator +from traitsui.testing.tester.qt4.common_ui_targets import LocatedTextbox + + +def resolve_location_slider(wrapper, location): + """ Solver from a UIWrapper wrapped Range Editor to a LocatedTextbox + containing the textbox of interest + + If there are any conflicts, an error will occur. + + Parameters + ---------- + wrapper : UIWrapper + Wrapper containing the Range Editor target. + location : locator.WidgetType + The location we are looking to resolve. + """ + if location == locator.WidgetType.textbox: + return LocatedTextbox(textbox=wrapper.target.control.text) + if location in [locator.WidgetType.slider]: + raise NotImplementedError( + f"Logic for interacting with the {location}" + " has not been implemented." + ) + raise ValueError( + f"Unable to resolve {location} on {wrapper.target}." + " Currently supported: {locator.WidgetType.textbox}" + ) + + +def resolve_location_range_text(wrapper, location): + """ Solver from a UIWrapper wrapped RangeTextEditor to a LocatedTextbox + containing the textbox of interest + + If there are any conflicts, an error will occur. + + Parameters + ---------- + wrapper : UIWrapper + Wrapper containing the RangeTextEditor target. + location : locator.WidgetType + The location we are looking to resolve. + """ + + if location == locator.WidgetType.textbox: + return LocatedTextbox(textbox=wrapper.target.control) + raise ValueError( + f"Unable to resolve {location} on {wrapper.target}." + " Currently supported: {locator.WidgetType.textbox}" + ) + + +def register(registry): + """ Register interactions for the given registry. + + If there are any conflicts, an error will occur. + + Parameters + ---------- + registry : TargetRegistry + The registry being registered to. + """ + + targets = [SimpleSliderEditor, + LogRangeSliderEditor, + LargeRangeSliderEditor] + for target_class in targets: + registry.register_solver( + target_class=target_class, + locator_class=locator.WidgetType, + solver=resolve_location_slider, + ) + + registry.register_solver( + target_class=RangeTextEditor, + locator_class=locator.WidgetType, + solver=resolve_location_range_text, + ) diff --git a/traitsui/testing/tester/wx/common_ui_targets.py b/traitsui/testing/tester/wx/common_ui_targets.py new file mode 100644 index 000000000..97f6ed5da --- /dev/null +++ b/traitsui/testing/tester/wx/common_ui_targets.py @@ -0,0 +1,65 @@ +# Copyright (c) 2005-2020, Enthought, Inc. +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only +# under the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +# + +""" This module contains targets for UIWrapper so that the logic related to +them can be reused. All handlers and solvers for these objects are +registered to the default registry via the register class methods. To use the +logic in these objects, one simply needs to register a solver with their +target_class of choice to one of these as the locator_class. For an example, +see the implementation of range_editor. +""" + +from traitsui.testing.tester import command, query +from traitsui.testing.tester.wx import helpers + + +class LocatedTextbox: + """ Wrapper class for a located Textbox in Wx. + + Parameters + ---------- + textbox : Instance of wx.TextCtrl + """ + + def __init__(self, textbox): + self.textbox = textbox + + @classmethod + def register(cls, registry): + """ Class method to register interactions on a LocatedTextbox for the + given registry. + + If there are any conflicts, an error will occur. + + Parameters + ---------- + registry : TargetRegistry + The registry being registered to. + """ + handlers = [ + (command.KeySequence, + (lambda wrapper, interaction: helpers.key_sequence_text_ctrl( + wrapper.target.textbox, interaction, wrapper.delay))), + (command.KeyClick, + (lambda wrapper, interaction: helpers.key_click_text_ctrl( + wrapper.target.textbox, interaction, wrapper.delay))), + (command.MouseClick, + (lambda wrapper, _: helpers.mouse_click_object( + wrapper.target.textbox, wrapper.delay))), + (query.DisplayedText, + lambda wrapper, _: wrapper.target.textbox.GetValue()), + ] + for interaction_class, handler in handlers: + registry.register_handler( + target_class=cls, + interaction_class=interaction_class, + handler=handler, + ) diff --git a/traitsui/testing/tester/wx/default_registry.py b/traitsui/testing/tester/wx/default_registry.py index 909789f71..e49ae8b6c 100644 --- a/traitsui/testing/tester/wx/default_registry.py +++ b/traitsui/testing/tester/wx/default_registry.py @@ -10,8 +10,10 @@ # from traitsui.testing.tester.registry import TargetRegistry +from traitsui.testing.tester.wx import common_ui_targets from traitsui.testing.tester.wx.implementation import ( button_editor, + range_editor, text_editor, ) @@ -27,10 +29,15 @@ def get_default_registry(): """ registry = TargetRegistry() + common_ui_targets.LocatedTextbox.register(registry) + # ButtonEditor button_editor.register(registry) # TextEditor text_editor.register(registry) + # RangeEditor + range_editor.register(registry) + return registry diff --git a/traitsui/testing/tester/wx/helpers.py b/traitsui/testing/tester/wx/helpers.py index 73a1ea71c..985329d1b 100644 --- a/traitsui/testing/tester/wx/helpers.py +++ b/traitsui/testing/tester/wx/helpers.py @@ -110,7 +110,14 @@ def key_click_text_ctrl(control, interaction, delay): raise Disabled("{!r} is disabled.".format(control)) if not control.HasFocus(): control.SetFocus() - key_click(control, interaction.key, delay) + # EmulateKeyPress in key_click seems to not be handling "Enter" + # correctly. + if interaction.key == "Enter": + wx.MilliSleep(delay) + event = wx.CommandEvent(wx.EVT_TEXT_ENTER.typeId, control.GetId()) + control.ProcessEvent(event) + else: + key_click(control, interaction.key, delay) def key_sequence_text_ctrl(control, interaction, delay): diff --git a/traitsui/testing/tester/wx/implementation/range_editor.py b/traitsui/testing/tester/wx/implementation/range_editor.py new file mode 100644 index 000000000..cf1a29119 --- /dev/null +++ b/traitsui/testing/tester/wx/implementation/range_editor.py @@ -0,0 +1,94 @@ +# Copyright (c) 2005-2020, Enthought, Inc. +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only +# under the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +# +from traitsui.wx.range_editor import ( + LargeRangeSliderEditor, + LogRangeSliderEditor, + RangeTextEditor, + SimpleSliderEditor, +) + +from traitsui.testing.tester import locator +from traitsui.testing.tester.wx.common_ui_targets import LocatedTextbox + + +def resolve_location_slider(wrapper, location): + """ Solver from a UIWrapper wrapped Range Editor to a LocatedTextbox + containing the textbox of interest + + If there are any conflicts, an error will occur. + + Parameters + ---------- + wrapper : UIWrapper + Wrapper containing the Range Editor target. + location : locator.WidgetType + The location we are looking to resolve. + """ + if location == locator.WidgetType.textbox: + return LocatedTextbox(textbox=wrapper.target.control.text) + if location in [locator.WidgetType.slider]: + raise NotImplementedError( + f"Logic for interacting with the {location}" + " has not been implemented." + ) + raise ValueError( + f"Unable to resolve {location} on {wrapper.target}." + " Currently supported: {locator.WidgetType.textbox}" + ) + + +def resolve_location_range_text(wrapper, location): + """ Solver from a UIWrapper wrapped RangeTextEditor to a LocatedTextbox + containing the textbox of interest + + If there are any conflicts, an error will occur. + + Parameters + ---------- + wrapper : UIWrapper + Wrapper containing the RangeTextEditor target. + location : locator.WidgetType + The location we are looking to resolve. + """ + + if location == locator.WidgetType.textbox: + return LocatedTextbox(textbox=wrapper.target.control) + raise ValueError( + f"Unable to resolve {location} on {wrapper.target}." + " Currently supported: {locator.WidgetType.textbox}" + ) + + +def register(registry): + """ Register interactions for the given registry. + + If there are any conflicts, an error will occur. + + Parameters + ---------- + registry : TargetRegistry + The registry being registered to. + """ + + targets = [SimpleSliderEditor, + LogRangeSliderEditor, + LargeRangeSliderEditor] + for target_class in targets: + registry.register_solver( + target_class=target_class, + locator_class=locator.WidgetType, + solver=resolve_location_slider, + ) + registry.register_solver( + target_class=RangeTextEditor, + locator_class=locator.WidgetType, + solver=resolve_location_range_text, + ) diff --git a/traitsui/tests/editors/test_range_editor.py b/traitsui/tests/editors/test_range_editor.py index 1041bc649..e03284b76 100644 --- a/traitsui/tests/editors/test_range_editor.py +++ b/traitsui/tests/editors/test_range_editor.py @@ -1,11 +1,11 @@ import unittest from traits.api import HasTraits, Int -from traitsui.api import RangeEditor, UItem, View +from traitsui.api import Item, RangeEditor, UItem, View +from traitsui.testing.tester import command, locator, query +from traitsui.testing.tester.ui_tester import UITester from traitsui.tests._tools import ( - create_ui, requires_toolkit, - reraise_exceptions, ToolkitName, ) @@ -33,8 +33,8 @@ def check_range_enum_editor_format_func(self, style): ) ) - with reraise_exceptions(),\ - create_ui(obj, dict(view=view)) as ui: + tester = UITester() + with tester.create_ui(obj, dict(view=view)) as ui: editor = ui.get_editors("value")[0] # No formatting - simple strings @@ -49,3 +49,35 @@ def test_simple_editor_format_func(self): def test_custom_editor_format_func(self): self.check_range_enum_editor_format_func("custom") + + def check_set_with_text(self, mode): + model = RangeModel() + view = View( + Item( + "value", + editor=RangeEditor(low=1, high=12, mode=mode) + ) + ) + tester = UITester() + with tester.create_ui(model, dict(view=view)) as ui: + number_field = tester.find_by_name(ui, "value") + text = number_field.locate(locator.WidgetType.textbox) + for _ in range(5): + text.perform(command.KeyClick("Backspace")) + text.perform(command.KeyClick("4")) + text.perform(command.KeyClick("Enter")) + displayed = text.inspect(query.DisplayedText()) + self.assertEqual(model.value, 4) + self.assertEqual(displayed, str(model.value)) + + def test_simple_slider_editor_set_with_text(self): + return self.check_set_with_text(mode='slider') + + def test_large_range_slider_editor_set_with_text(self): + return self.check_set_with_text(mode='xslider') + + def test_log_range_slider_editor_set_with_text(self): + return self.check_set_with_text(mode='logslider') + + def test_range_text_editor_set_with_text(self): + return self.check_set_with_text(mode='text') diff --git a/traitsui/tests/editors/test_range_editor_text.py b/traitsui/tests/editors/test_range_editor_text.py index f9a3fa101..46b2f6971 100644 --- a/traitsui/tests/editors/test_range_editor_text.py +++ b/traitsui/tests/editors/test_range_editor_text.py @@ -26,6 +26,8 @@ from traitsui.view import View from traitsui.editors.range_editor import RangeEditor +from traitsui.testing.tester import command, locator, query +from traitsui.testing.tester.ui_tester import UITester from traitsui.tests._tools import ( create_ui, press_ok_button, @@ -68,39 +70,20 @@ 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: - + tester = UITester() + with tester.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") + text = tester.find_by_name(ui, "number").locate(locator.WidgetType.textbox) + text.perform(command.KeyClick("1")) + text.perform(command.KeyClick("Enter")) # the number traits should be between 3 and 8 self.assertTrue(3 <= num.number <= 8) - @requires_toolkit([ToolkitName.qt]) - 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() - - # the number trait should be 4 extactly - self.assertEqual(num.number, 4.0) - if __name__ == "__main__": # Executing the file opens the dialog for manual testing - num = NumberWithTextEditor() + num = NumberWithRangeEditor() num.configure_traits() print(num.number) diff --git a/traitsui/wx/range_editor.py b/traitsui/wx/range_editor.py index 27b25d566..36805ca82 100644 --- a/traitsui/wx/range_editor.py +++ b/traitsui/wx/range_editor.py @@ -527,6 +527,10 @@ def update_object_on_enter(self, event): """ if isinstance(event, wx.FocusEvent): event.Skip() + # It is possible the event is processed after the control is removed + # from the editor + if self.control is None: + return try: value = self.control.text.GetValue().strip() try: @@ -848,8 +852,8 @@ def update_object(self, event): if isinstance(event, wx.FocusEvent): event.Skip() - # There are cases where this method is called with self.control == - # None. + # It is possible the event is processed after the control is removed + # from the editor if self.control is None: return