diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 47cb43f5..a9228710 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,8 +3,7 @@ Contributing to dtool-lookup-gui Code style ---------- -Always follow [PEP-8](https://www.python.org/dev/peps/pep-0008/) with the exception of line -breaks. +Always follow [PEP-8](https://www.python.org/dev/peps/pep-0008/) with the exception of line breaks. Development branches -------------------- @@ -23,3 +22,34 @@ Prepend you commits with a shortcut indicating the type of changes they contain: * `MAINT`: Maintenance (e.g. fixing a typo) * `TST`: Changes to the unit test environment * `WIP`: Work in progress + +GTK-specific +------------ + +The dtool-lookup-gui uses [GTK 3](/https://docs.gtk.org/gtk3/) to provide a platform-independent graphical user interface (GUI). +GTK originates at the [GNOME](https://wiki.gnome.org/) project. +At it's core sits [GLib](https://gitlab.gnome.org/GNOME/glib/), and on top of that lives the [GObject](https://docs.gtk.org/gobject/) library. +Again higher live the [Gio](https://docs.gtk.org/gio/) and [Gdk](https://docs.gtk.org/gdk4/) libraries and on top of them all the GTK toolkit. +The dtool-lookup-gui interfaces all GLib, GObject, Gio, Gtk, and Gdk functionality with [PyGObject](https://pygobject.readthedocs.io/en/latest/). +The [Python GTK+ 3 Tutorial](https://python-gtk-3-tutorial.readthedocs.io/en/latest/) helps with learning how to build GTK applications with Python. + +Signals and events, and the event loop +-------------------------------------- + +GTK applications run asynchronously in the [GLib](https://docs.gtk.org/glib/) main event loop. [gbulb](https://github.com/beeware/gbulb) provides an implementation of the [PEP 3156](https://peps.python.org/pep-3156/) interface to this GLib event loop for Python-native use of [asyncio](https://docs.python.org/3/library/asyncio.html). +Within the dtool-lookup-gui's code, and importantly within `test/conftest.py` you will hence find `gbulb` bits that configure `asyncio` and `GTK` to share the same event loop. + +Gio.Action +---------- + +Next to signals and events, actions are an important concept. +[Actions](https://docs.gtk.org/gio/iface.Action.html) implement functionality that is not necessarily bound to a specific element in the graphical representation. +Wrapping as much behavior as possible into atomic actions helps to structure the code and let the application evolve in the spirit of a clean separation between model, view, and controlling logic ([MVC software design pattern](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller)). +Furthermore, actions can be evoked by other means than user interaction via the GUI, such as via the command line or a keyboard shortcut. +They facilitate writing unit tests as well. + +When implementing new features, think about how to break them down into atomic actions. +In most cases, you will want to use the [Gio.SimpleAction](https://lazka.github.io/pgi-docs/Gio-2.0/interfaces/SimpleAction.html) interface. +You will find usage examples within the code, in the [GTK Python developer handbook: Working with GIO Actions](https://bharatkalluri.gitbook.io/gnome-developer-handbook/writing-apps-using-python/working-with-gio-gactions). +Write actions as methods prefixed with `do_*`, e.g. `do_search` and attach them to the app itself or to the window they relate to. +Write at least one unit test for each action, e.g. `test_do_search` for the action `do_search`. \ No newline at end of file diff --git a/README.rst b/README.rst index 2585c8b8..6008c25b 100644 --- a/README.rst +++ b/README.rst @@ -218,6 +218,11 @@ The GUI uses custom Gtk widgets. To edit the the XML UI definition files with Glade_, add the directory ``glade/catalog`` to `Extra Catalog & Template paths` within Glade's preferences dialog. +Running unit tests +^^^^^^^^^^^^^^^^^^ + +Running the unit tests requires `pytest` and `pytest-asyncio`. Then, run all tests from repository root with `pytest`. + Funding ------- diff --git a/test/test_main_app_actions.py b/test/test_main_app_actions.py index e69de29b..2c5cff68 100644 --- a/test/test_main_app_actions.py +++ b/test/test_main_app_actions.py @@ -0,0 +1,232 @@ +import json +import logging +import types +from unittest import mock +from unittest.mock import patch, mock_open, MagicMock, ANY, AsyncMock +import pytest +import dtoolcore + +from dtool_lookup_gui.models.settings import settings as app_settings +from dtool_lookup_api.core.LookupClient import authenticate + + +@pytest.mark.asyncio +async def test_app_id(app): + assert app.get_application_id() == 'de.uni-freiburg.dtool-lookup-gui' + + +@pytest.fixture +def settings(): + """Provide test case with app settings.""" + yield app_settings + + +@pytest.mark.asyncio +async def test_do_toggle_logging(app): + """Test do_toggle_logging action.""" + + with patch('logging.disable') as mock_logging_disable: + # Mock value to mimic the GVariant returned by the value parameter + value = MagicMock() + value.get_boolean.return_value = True # Testing the True case + + # Mock action to mimic the Gio.SimpleAction + action = MagicMock() + + # Perform the toggle logging action for enabling logging + app.do_toggle_logging(action, value) + + # Assert that logging.disable was called with logging.NOTSET + mock_logging_disable.assert_called_once_with(logging.NOTSET) + action.set_state.assert_called_once_with(value) + + # Reset mocks to test the False case + mock_logging_disable.reset_mock() + action.set_state.reset_mock() + value.get_boolean.return_value = False + + # Perform the toggle logging action for disabling logging + app.do_toggle_logging(action, value) + + # Assert that logging.disable was called with logging.WARNING + mock_logging_disable.assert_called_once_with(logging.WARNING) + action.set_state.assert_called_once_with(value) + + +@pytest.mark.asyncio +async def test_do_set_loglevel(app): + """Test do_set_loglevel action.""" + + with patch.object(logging.getLogger(), 'setLevel') as mock_set_level: + # Mock values for loglevel + loglevel = 20 # Example log level (e.g., logging.INFO) + + # Mock value to mimic the GVariant returned by the value parameter + value = MagicMock() + value.get_uint16.return_value = loglevel + + # Mock action to mimic the Gio.SimpleAction + action = MagicMock() + action_state = MagicMock() + action_state.get_uint16.return_value = 0 + action.get_state.return_value = action_state + + # Perform the set loglevel action + app.do_set_loglevel(action, value) + + # Assert that the root logger's setLevel was called with the correct level + mock_set_level.assert_called_once_with(loglevel) + action.set_state.assert_called_once_with(value) + + +@pytest.mark.asyncio +async def test_do_set_logfile(app): + """Test do_set_logfile action.""" + with patch('logging.FileHandler') as mock_file_handler, \ + patch.object(logging.getLogger(), 'addHandler') as mock_add_handler: + # Mock values for logfile + logfile = "/path/to/logfile.log" + + # Mock value to mimic the GVariant returned by the value parameter + value = MagicMock() + value.get_string.return_value = logfile + + # Mock action to mimic the Gio.SimpleAction + action = MagicMock() + action_state = MagicMock() + action_state.get_string.return_value = "" + action.get_state.return_value = action_state + + # Perform the set logfile action + app.do_set_logfile(action, value) + + # Assert that logging.FileHandler was called with the correct path + mock_file_handler.assert_called_once_with(logfile) + + # Assert that the FileHandler was added to the root logger + mock_add_handler.assert_called_once_with(ANY) + + # Assert that the action's state was set to the new logfile value + action.set_state.assert_called_once_with(value) + + +@pytest.mark.asyncio +async def test_do_reset_config(app, settings): + """Test do_reset_config action.""" + with patch('os.remove') as mock_remove, \ + patch.object(app, 'emit') as mock_emit: # Patch the emit method + + settings.reset = MagicMock() # Mock the settings reset method + + # Perform the reset config action + app.activate_action('reset-config') + + # The 'do_reset_config' action removes and replaces the dtool.json + # config file. + # Assert that os.remove has been called with the correct path + mock_remove.assert_called_once_with(dtoolcore.utils.DEFAULT_CONFIG_PATH) + + # The 'do_reset_config' action calls the reset method on the + # Gio.Setting property of the settings object. + # Assert that the settings.reset was called once. + settings.reset.assert_called_once() + + # The emit method is called when a signal is emitted. + # The 'do_reset_config' action is expected to emit a + # 'dtool-config-changed' signal. + # Assert that the 'dtool-config-changed' signal has been emitted + mock_emit.assert_called_once_with('dtool-config-changed') + + +@pytest.mark.asyncio +async def test_do_import_config(app, settings): + """Test do_import_config action.""" + mock_config = {'key1': 'value1', 'key2': 'value2'} + # Define the mock config file path + config_file_path = '/path/to/mock/config.json' + + with patch('builtins.open', mock_open(read_data=json.dumps(mock_config))) as mock_file, \ + patch('json.load', return_value=mock_config), \ + patch('dtoolcore.utils.write_config_value_to_file') as mock_write_config, \ + patch.object(app, 'emit') as mock_emit: + # Create a mock for the GLib.Variant object + mock_value = mock.Mock() + mock_value.get_string.return_value = config_file_path + + # Perform the import config action + app.do_import_config('import-config', mock_value) + + # Assert that the config file is opened correctly with the specified path + mock_file.assert_any_call(config_file_path, 'r') + + # Assert that the config values are written to file + for key, value in mock_config.items(): + mock_write_config.assert_any_call(key, value) + + # Assert that the 'dtool-config-changed' signal has been emitted + mock_emit.assert_called_once_with('dtool-config-changed') + + +@pytest.mark.asyncio +async def test_do_export_config(app, settings): + """Test do_export_config action.""" + mock_config = {'key1': 'value1', 'key2': 'value2'} + # Define the mock config file path + config_file_path = '/path/to/mock/config.json' + + # Setup mock open + mock_file_handle = mock_open() + with patch('builtins.open', mock_file_handle) as mock_file, \ + patch('dtoolcore.utils._get_config_dict_from_file', return_value=mock_config), \ + patch.object(app, 'emit') as mock_emit: + # Create a mock for the GLib.Variant object + mock_value = mock.Mock() + mock_value.get_string.return_value = config_file_path + + # Perform the export config action + app.do_export_config('export-config', mock_value) + + # Assert that the config file is opened correctly with the specified path + mock_file.assert_called_once_with(config_file_path, 'w') + + # Concatenate all the calls to write and compare with expected JSON + written_data = ''.join(call_arg.args[0] for call_arg in mock_file_handle().write.call_args_list) + expected_file_content = json.dumps(mock_config, indent=4) + assert written_data == expected_file_content + + +@pytest.mark.asyncio +async def test_do_renew_token(app): + """Test do_renew_token action.""" + with patch('asyncio.create_task') as mock_create_task, \ + patch.object(app, 'emit') as mock_emit: # Patch the emit method + + # Mock values for username, password, and auth_url + username = "testuser" + password = "testpass" + auth_url = "http://auth.example.com" + + # Tuple variant to mimic the value parameter in the method + value = MagicMock() + value.unpack.return_value = (username, password, auth_url) + + # Perform the renew token action + app.do_renew_token(None, value) + + # Assert that asyncio.create_task was called with the retrieve_token coroutine + mock_create_task.assert_called_once() + args, kwargs = mock_create_task.call_args + retrieve_token_coro = args[0] + assert isinstance(retrieve_token_coro, types.CoroutineType) + + # Assert that the coroutine was called with the correct arguments + assert retrieve_token_coro.cr_code.co_name == 'retrieve_token' + assert retrieve_token_coro.cr_frame.f_locals['auth_url'] == auth_url + assert retrieve_token_coro.cr_frame.f_locals['username'] == username + assert retrieve_token_coro.cr_frame.f_locals['password'] == password + + # The 'do_renew_token' action is expected to emit a + # 'dtool-config-changed' signal after successful token renewal. + # Since the actual token retrieval is asynchronous and mocked, + # we cannot assert this directly in this test. + # mock_emit.assert_called_once_with('dtool-config-changed') \ No newline at end of file diff --git a/test/test_main_window_actions.py b/test/test_main_window_actions.py index e69de29b..a4b5514c 100644 --- a/test/test_main_window_actions.py +++ b/test/test_main_window_actions.py @@ -0,0 +1,3 @@ +import asyncio +import logging +import pytest \ No newline at end of file