diff --git a/gooey/gui/application.py b/gooey/gui/application.py index 6593dcf..90187de 100644 --- a/gooey/gui/application.py +++ b/gooey/gui/application.py @@ -18,7 +18,7 @@ from gooey.util.functional import merge def run(build_spec): - app = build_app(build_spec) + app, _ = build_app(build_spec) app.MainLoop() @@ -29,8 +29,5 @@ def build_app(build_spec): imagesPaths = image_repository.loadImages(build_spec['image_dir']) gapp = GooeyApplication(merge(build_spec, imagesPaths)) gapp.Show() - return app - - - + return (app, gapp) diff --git a/gooey/gui/components/widgets/radio_group.py b/gooey/gui/components/widgets/radio_group.py index 5791ccc..70d03be 100644 --- a/gooey/gui/components/widgets/radio_group.py +++ b/gooey/gui/components/widgets/radio_group.py @@ -20,7 +20,7 @@ class RadioGroup(BaseWidget): self.selected = None self.widgets = self.createWidgets() self.arrange() - self.applyStyleRules() + for button in self.radioButtons: button.Bind(wx.EVT_LEFT_DOWN, self.handleButtonClick) @@ -31,6 +31,8 @@ class RadioGroup(BaseWidget): self.selected.SetValue(True) self.handleImplicitCheck() + self.applyStyleRules() + def getValue(self): for button, widget in zip(self.radioButtons, self.widgets): @@ -85,6 +87,7 @@ class RadioGroup(BaseWidget): # if it is not in the required section, allow it to be deselected if not self.widgetInfo['required']: self.selected.SetValue(False) + self.selected = None self.applyStyleRules() self.handleImplicitCheck() @@ -104,9 +107,9 @@ class RadioGroup(BaseWidget): if isinstance(widget, CheckBox): widget.hideInput() if not button.GetValue(): # not checked - widget.widget.Disable() + widget.Disable() else: - widget.widget.Enable() + widget.Enable() def handleImplicitCheck(self): """ @@ -141,5 +144,10 @@ class RadioGroup(BaseWidget): Instantiate the Gooey Widgets that are used within the RadioGroup """ from gooey.gui.components import widgets - return [getattr(widgets, item['type'])(self, item) - for item in getin(self.widgetInfo, ['data', 'widgets'], [])] + widgets = [getattr(widgets, item['type'])(self, item) + for item in getin(self.widgetInfo, ['data', 'widgets'], [])] + # widgets should be disabled unless + # explicitly selected + for widget in widgets: + widget.Disable() + return widgets diff --git a/gooey/python_bindings/gooey_decorator.py b/gooey/python_bindings/gooey_decorator.py index fdde9a3..ebc6485 100644 --- a/gooey/python_bindings/gooey_decorator.py +++ b/gooey/python_bindings/gooey_decorator.py @@ -18,6 +18,38 @@ from . import cmd_args IGNORE_COMMAND = '--ignore-gooey' +# TODO: use these defaults in the decorator and migrate to a flat **kwargs +# They're pulled out here for wiring up instances in the tests. +# Some fiddling is needed before I can make the changes to make the swap to +# `defaults` + **kwargs overrides. +defaults = { + 'advanced': True, + 'language': 'english', + 'auto_start': False, # TODO: add this to the docs. Used to be `show_config=True` + 'target': None, + 'program_name': None, + 'program_description': None, + 'default_size': (610, 530), + 'use_legacy_titles': True, + 'required_cols': 2, + 'optional_cols': 2, + 'dump_build_config': False, + 'load_build_config': None, + 'monospace_display': False, # TODO: add this to the docs + 'image_dir': '::gooey/default', + 'language_dir': getResourcePath('languages'), + 'progress_regex': None, # TODO: add this to the docs + 'progress_expr': None, # TODO: add this to the docs + 'hide_progress_msg': False, # TODO: add this to the docs + 'disable_progress_bar_animation': False, + 'disable_stop_button': False, + 'group_by_type': True, + 'header_height': 80, + 'navigation': 'SIDEBAR', # TODO: add this to the docs + 'tabbed_groups': False, + 'use_cmd_args': False, +} + # TODO: kwargs all the things def Gooey(f=None, advanced=True, diff --git a/gooey/tests/harness.py b/gooey/tests/harness.py new file mode 100644 index 0000000..876b25d --- /dev/null +++ b/gooey/tests/harness.py @@ -0,0 +1,22 @@ +from contextlib import contextmanager + +import wx + +from gui import application +from python_bindings.config_generator import create_from_parser +from python_bindings.gooey_decorator import defaults + + +@contextmanager +def instrumentGooey(parser, **kwargs): + """ + Context manager used during testing for setup/tear down of the + WX infrastructure during subTests. + """ + buildspec = create_from_parser(parser, "", **defaults) + app, gooey = application.build_app(buildspec) + try: + yield (app, gooey) + finally: + wx.CallAfter(app.ExitMainLoop) + app.Destroy() \ No newline at end of file diff --git a/gooey/tests/test_radiogroup.py b/gooey/tests/test_radiogroup.py new file mode 100644 index 0000000..54ad285 --- /dev/null +++ b/gooey/tests/test_radiogroup.py @@ -0,0 +1,189 @@ +import unittest + +import wx + +from gooey import GooeyParser +from tests.harness import instrumentGooey + + +class TestRadioGroupBehavior(unittest.TestCase): + + def mutext_group(self, options): + """ + Basic radio group consisting of two options. + """ + parser = GooeyParser() + group = parser.add_mutually_exclusive_group(**options) + group.add_argument("-b", type=str) + group.add_argument("-d", type=str, widget="DateChooser") + return parser + + + def test_initial_selection_options(self): + """ + Ensure that the initial_selection GooeyOption behaves as expected. + """ + # each pair in the below datastructure represents input/output + # First position: kwargs which will be supplied to the parser + # Second position: expected indices which buttons/widgets should be enabled/disabled + testCases = [ + [{'required': True, 'gooey_options': {}}, + {'selected': None, 'enabled': [], 'disabled': [0, 1]}], + + # Issue #517 - initial section with required=True was not enabling + # the inner widget + [{'required': True, 'gooey_options': {"initial_selection": 0}}, + {'selected': 0, 'enabled': [0], 'disabled': [1]}], + + [{'required': True, 'gooey_options': {"initial_selection": 1}}, + {'selected': 1, 'enabled': [1], 'disabled': [0]}], + + [{'required': False, 'gooey_options': {}}, + {'selected': None, 'enabled': [], 'disabled': [0, 1]}], + + [{'required': False, 'gooey_options': {"initial_selection": 0}}, + {'selected': 0, 'enabled': [0], 'disabled': [1]}], + + [{'required': False, 'gooey_options': {"initial_selection": 1}}, + {'selected': 1, 'enabled': [1], 'disabled': [0]}], + ] + for options, expected in testCases: + parser = self.mutext_group(options) + with self.subTest(options): + with instrumentGooey(parser) as (app, gooeyApp): + radioGroup = gooeyApp.configs[0].reifiedWidgets[0] + + # verify that the checkboxes themselves are correct + if expected['selected'] is not None: + self.assertEqual( + radioGroup.selected, + radioGroup.radioButtons[expected['selected']]) + else: + self.assertEqual(radioGroup.selected, None) + + # verify the widgets contained in the radio group + # are in the correct state + for enabled in expected['enabled']: + # The widget contained within the group should be enabled + self.assertTrue(radioGroup.widgets[enabled].IsEnabled()) + + # make sure all widgets other than the selected + # are disabled + for enabled in expected['disabled']: + self.assertFalse(radioGroup.widgets[enabled].IsEnabled()) + + + def test_optional_radiogroup_click_behavior(self): + """ + Testing that select/deselect behaves as expected + """ + testcases = [ + self.click_scenarios_optional_widget(), + self.click_scenarios_required_widget(), + self.click_scenarios_initial_selection() + ] + + for testcase in testcases: + with self.subTest(testcase['name']): + # wire up the parse with our test case options + parser = self.mutext_group(testcase['input']) + + with instrumentGooey(parser) as (app, gooeyApp): + radioGroup = gooeyApp.configs[0].reifiedWidgets[0] + + for scenario in testcase['scenario']: + targetButton = scenario['clickButton'] + + event = wx.CommandEvent(wx.wxEVT_LEFT_DOWN, wx.NewId()) + event.SetEventObject(radioGroup.radioButtons[targetButton]) + + radioGroup.radioButtons[targetButton].ProcessEvent(event) + + expectedEnabled, expectedDisabled = scenario['postState'] + + for index in expectedEnabled: + self.assertEqual(radioGroup.selected, radioGroup.radioButtons[index]) + self.assertTrue(radioGroup.widgets[index].IsEnabled()) + + for index in expectedDisabled: + self.assertNotEqual(radioGroup.selected, radioGroup.radioButtons[index]) + self.assertFalse(radioGroup.widgets[index].IsEnabled()) + + + def click_scenarios_optional_widget(self): + return { + 'name': 'click_scenarios_optional_widget', + 'input': {'required': False}, + 'scenario': [ + # clicking enabled the button + {'clickButton': 0, + 'postState': [[0], [1]]}, + + # clicking again disables the button (*when not required*) + {'clickButton': 0, + 'postState': [[], [0, 1]]}, + + # clicking group 2 enabled it + {'clickButton': 1, + 'postState': [[1], [0]]}, + + # and similarly clicking group 2 again disables it + {'clickButton': 1, + 'postState': [[], [0, 1]]}, + + # enable second group + {'clickButton': 1, + 'postState': [[1], [0]]}, + + # can switch to group one + {'clickButton': 0, + 'postState': [[0], [1]]}, + ] + } + + def click_scenarios_required_widget(self): + return { + 'name': 'click_scenarios_required_widget', + 'input': {'required': True}, + 'scenario': [ + # clicking enables the button + {'clickButton': 0, + 'postState': [[0], [1]]}, + + # unlike the the optional case, this + # has no effect. You cannot _not_ select something + # when it is required. + {'clickButton': 0, + 'postState': [[0], [1]]}, + + # we can select a different button + {'clickButton': 1, + 'postState': [[1], [0]]}, + + # again, if we click it again, we cannot deselect it + {'clickButton': 1, + 'postState': [[1], [0]]}, + + # we can click back to the other group + {'clickButton': 0, + 'postState': [[0], [1]]}, + ]} + + def click_scenarios_initial_selection(self): + return { + 'name': 'click_scenarios_initial_selection', + 'input': {'required': False, 'gooey_options': {'initial_selection': 0}}, + 'scenario': [ + # we start already selected via GooeyOptions. As such, + # clicking on the radiobutton should deselect it + {'clickButton': 0, + 'postState': [[], [0, 1]]}, + # clicking again reselected it + {'clickButton': 0, + 'postState': [[0], [1]]}, + ]} + + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file