diff --git a/Makefile b/Makefile index 0ac7e7c0..31216dd2 100644 --- a/Makefile +++ b/Makefile @@ -72,7 +72,7 @@ lint: flake8 hamster-dbus tests test: - py.test $(TEST_ARGS) tests/ + xvfb-run py.test $(TEST_ARGS) tests/ test-all: tox diff --git a/ci/run_docker.sh b/ci/run_docker.sh index c39641f2..fb455830 100755 --- a/ci/run_docker.sh +++ b/ci/run_docker.sh @@ -11,13 +11,11 @@ xvfb=$! export DISPLAY=:99 -pip install --upgrade pip pip install -r requirements/test.pip - -python setup.py install +pip install . make resources - make test-all + # See: https://docs.codecov.io/docs/testing-with-docker for details bash <(curl -s https://codecov.io/bash) test $err = 0 diff --git a/hamster_gtk/hamster_gtk.py b/hamster_gtk/hamster_gtk.py index 07f98a96..8950aa87 100755 --- a/hamster_gtk/hamster_gtk.py +++ b/hamster_gtk/hamster_gtk.py @@ -117,6 +117,11 @@ class SignalHandler(GObject.GObject): them via its class instances. """ + # [TODO] + # Explain semantics of each signal + # [TODO] + # Add signals for all changed hamster-lib objects? + __gsignals__ = { str('facts-changed'): (GObject.SIGNAL_RUN_LAST, None, ()), str('daterange-changed'): (GObject.SIGNAL_RUN_LAST, None, (GObject.TYPE_PYOBJECT,)), @@ -255,6 +260,7 @@ def _reload_config(self): """Reload configuration from designated store.""" config = self._get_config_from_file() self._config = config + self.config = config return config def _config_changed(self, sender): @@ -279,6 +285,8 @@ def _get_default_config(self): # Frontend 'autocomplete_activities_range': 30, 'autocomplete_split_activity': False, + 'tracking_show_recent_activities': True, + 'tracking_recent_activities_count': 6, } def _config_to_configparser(self, config): @@ -315,6 +323,12 @@ def get_autocomplete_activities_range(): def get_autocomplete_split_activity(): return text_type(config['autocomplete_split_activity']) + def get_tracking_show_recent_activities(): + return text_type(config['tracking_show_recent_activities']) + + def get_tracking_recent_activities_count(): + return text_type(config['tracking_recent_activities_count']) + cp_instance = SafeConfigParser() cp_instance.add_section('Backend') cp_instance.set('Backend', 'store', get_store()) @@ -329,6 +343,10 @@ def get_autocomplete_split_activity(): get_autocomplete_activities_range()) cp_instance.set('Frontend', 'autocomplete_split_activity', get_autocomplete_split_activity()) + cp_instance.set('Frontend', 'tracking_show_recent_activities', + get_tracking_show_recent_activities()) + cp_instance.set('Frontend', 'tracking_recent_activities_count', + get_tracking_recent_activities_count()) return cp_instance @@ -385,6 +403,12 @@ def get_autocomplete_activities_range(): def get_autocomplete_split_activity(): return cp_instance.getboolean('Frontend', 'autocomplete_split_activity') + def get_tracking_show_recent_activities(): + return cp_instance.getboolean('Frontend', 'tracking_show_recent_activities') + + def get_tracking_recent_activities_count(): + return int(cp_instance.get('Frontend', 'tracking_recent_activities_count')) + result = { 'store': get_store(), 'day_start': get_day_start(), @@ -392,6 +416,8 @@ def get_autocomplete_split_activity(): 'tmpfile_path': get_tmpfile_path(), 'autocomplete_activities_range': get_autocomplete_activities_range(), 'autocomplete_split_activity': get_autocomplete_split_activity(), + 'tracking_show_recent_activities': get_tracking_show_recent_activities(), + 'tracking_recent_activities_count': get_tracking_recent_activities_count(), } result.update(get_db_config()) return result @@ -407,12 +433,9 @@ def _write_config_to_file(self, configparser_instance): def _get_config_from_file(self): """ - Return a config dictionary from acp_instanceg file. + Return a config dictionary from app_instance file. - If there is none create a default config file. This methods main job is - to convert strings from the loaded ConfigParser File to appropiate - instances suitable for our config dictionary. The actual data retrival - is provided by a hamster-lib helper function. + If there is none create a default config file. Returns: dict: Dictionary of config key/values. diff --git a/hamster_gtk/helpers.py b/hamster_gtk/helpers.py index 9eadf9b8..b62425f2 100644 --- a/hamster_gtk/helpers.py +++ b/hamster_gtk/helpers.py @@ -22,9 +22,12 @@ from __future__ import absolute_import, unicode_literals import datetime +import operator import re +from gettext import gettext as _ import six +from orderedset import OrderedSet from six import text_type @@ -88,6 +91,8 @@ def clear_children(widget): It seems GTK really does not have this build in. Iterating over all seems a bit blunt, but seems to be the way to do this. """ + # [TODO] + # Replace with Gtk.Container.foreach()? for child in widget.get_children(): child.destroy() return widget @@ -177,6 +182,50 @@ def decompose_raw_fact_string(text, raw=False): return result +# [TODO] +# Once LIB-251 has been fixed this should no longer be needed. +def get_recent_activities(controller, start, end): + """Return a list of all activities logged in facts within the given timeframe.""" + # [FIXME] + # This manual sorting within python is of course less than optimal. We stick + # with it for now as this is just a preliminary workaround helper anyway and + # effective sorting will need to be implemented by the storage backend in + # ``hamster-lib``. + facts = sorted(controller.facts.get_all(start=start, end=end), + key=operator.attrgetter('start'), reverse=True) + recent_activities = [fact.activity for fact in facts] + return OrderedSet(recent_activities) + + +def serialize_activity(activity, separator='@'): + """ + Provide a serialized string version of an activity. + + Args: + activity (Activity): ``Activity`` instance to serialize. + separator (str, optional): ``string`` used to separate ``activity.name`` and + ``category.name``. The separator will be omitted if + ``activity.category=None``. Defaults to ``@``. + + Returns: + str: A string representation of the passed activity. + """ + if not separator: + raise ValueError(_("No valid separator has been provided.")) + + category_text = None + + if activity.category: + category_text = activity.category.name + + if category_text: + result = '{activity_text}{separator}{category_text}'.format(activity_text=activity.name, + category_text=category_text, separator=separator) + else: + result = activity.name + return text_type(result) + + def get_delta_string(delta): """ Return a human readable representation of ``datetime.timedelta`` instance. diff --git a/hamster_gtk/overview/widgets/fact_grid.py b/hamster_gtk/overview/widgets/fact_grid.py index 8eff81cd..6c3cdade 100644 --- a/hamster_gtk/overview/widgets/fact_grid.py +++ b/hamster_gtk/overview/widgets/fact_grid.py @@ -201,18 +201,9 @@ def __init__(self, fact): def _get_activity_widget(self, fact): """Return widget to render the activity, including its related category.""" - # [FIXME] - # Once 'preferences/config' is live, we can change this. - # Most likly we do not actually need to jump through extra hoops as - # legacy hamster did but just use a i18n'ed string and be done. - if not fact.category: - category = 'not categorised' - else: - category = str(fact.category) activity_label = Gtk.Label() - activity_label.set_markup("{activity} - {category}".format( - activity=GObject.markup_escape_text(fact.activity.name), - category=GObject.markup_escape_text(category))) + label_text = helpers.serialize_activity(fact.activity) + activity_label.set_markup(GObject.markup_escape_text(label_text)) activity_label.props.halign = Gtk.Align.START return activity_label diff --git a/hamster_gtk/preferences/preferences_dialog.py b/hamster_gtk/preferences/preferences_dialog.py index 07a862bc..21583f43 100644 --- a/hamster_gtk/preferences/preferences_dialog.py +++ b/hamster_gtk/preferences/preferences_dialog.py @@ -30,9 +30,8 @@ from hamster_gtk.misc.widgets import LabelledWidgetsGrid from hamster_gtk.preferences.widgets import (ComboFileChooser, - HamsterSwitch, HamsterComboBoxText, - HamsterSpinButton, + HamsterSpinButton, HamsterSwitch, SimpleAdjustment, TimeEntry) @@ -80,6 +79,11 @@ def __init__(self, parent, app, initial, *args, **kwargs): ('autocomplete_split_activity', (_("Autocomplete activities and categories separately"), HamsterSwitch())), + ('tracking_show_recent_activities', + (_("Show recent activities for quickly starting tracking."), + HamsterSwitch())), + ('tracking_recent_activities_count', (_('How many recent activities?'), + HamsterSpinButton(SimpleAdjustment(0, GObject.G_MAXDOUBLE, 1)))), ]))), ] diff --git a/hamster_gtk/preferences/widgets/__init__.py b/hamster_gtk/preferences/widgets/__init__.py index d81ddd82..fa0a28a2 100644 --- a/hamster_gtk/preferences/widgets/__init__.py +++ b/hamster_gtk/preferences/widgets/__init__.py @@ -19,7 +19,7 @@ from .combo_file_chooser import ComboFileChooser # NOQA from .config_widget import ConfigWidget # NOQA -from .hamster_switch import HamsterSwitch # NOQA from .hamster_combo_box_text import HamsterComboBoxText # NOQA from .hamster_spin_button import HamsterSpinButton, SimpleAdjustment # NOQA +from .hamster_switch import HamsterSwitch # NOQA from .time_entry import TimeEntry # NOQA diff --git a/hamster_gtk/tracking/screens.py b/hamster_gtk/tracking/screens.py index 40b3d584..6c3232a4 100644 --- a/hamster_gtk/tracking/screens.py +++ b/hamster_gtk/tracking/screens.py @@ -169,6 +169,7 @@ def __init__(self, app, *args, **kwargs): spacing=10, *args, **kwargs) self._app = app self.set_homogeneous(False) + self._app.controller.signal_handler.connect('config-changed', self._on_config_changed) # [FIXME] # Refactor to call separate 'get_widget' methods instead. @@ -186,8 +187,16 @@ def __init__(self, app, *args, **kwargs): # Buttons start_button = Gtk.Button(label=_("Start Tracking")) start_button.connect('clicked', self._on_start_tracking_button) + self.start_button = start_button self.pack_start(start_button, False, False, 0) + # Recent activities + if self._app.config['tracking_show_recent_activities']: + self.recent_activities_widget = self._get_recent_activities_widget() + self.pack_start(self.recent_activities_widget, True, True, 0) + else: + self.recent_activities_widget = None + def _start_ongoing_fact(self): """ Start a new *ongoing fact*. @@ -230,6 +239,29 @@ def reset(self): """Clear all data entry fields.""" self.raw_fact_entry.props.text = '' + def set_raw_fact(self, raw_fact): + """Set the text in the raw fact entry.""" + self.raw_fact_entry.props.text = raw_fact + + def _get_recent_activities_widget(self): + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + grid = RecentActivitiesGrid(self, self._app.controller) + # We need to 'show' the grid early in order to make sure space is + # allocated to its children so they actually have a height that we can + # use. + grid.show_all() + # We fetch an arbitrary Button as height-reference [#224] + min_height = 0 + children = grid.get_children() + if children: + height = children[1].get_preferred_height()[1] + min_height = self._app.config['tracking_recent_activities_count'] * height + + scrolled_window.set_min_content_height(min_height) + scrolled_window.add(grid) + return scrolled_window + # Callbacks def _on_start_tracking_button(self, button): """Callback for the 'start tracking' button.""" @@ -238,3 +270,119 @@ def _on_start_tracking_button(self, button): def _on_raw_fact_entry_activate(self, evt): """Callback for when ``enter`` is pressed within the entry.""" self._start_ongoing_fact() + + def _on_config_changed(self, sender): + """Callback triggered when 'config-changed' event fired.""" + if self._app.config['tracking_show_recent_activities']: + # We re-create it even if one existed before because its parameters + # (e.g. size) may have changed. + if self.recent_activities_widget: + self.recent_activities_widget.destroy() + self.recent_activities_widget = self._get_recent_activities_widget() + self.pack_start(self.recent_activities_widget, True, True, 0) + else: + if self.recent_activities_widget: + self.recent_activities_widget.destroy() + self.recent_activities_widget = None + self.show_all() + + +class RecentActivitiesGrid(Gtk.Grid): + """A widget that lists recent activities and allows for quick continued tracking.""" + + def __init__(self, start_tracking_widget, controller, *args, **kwargs): + """ + Initiate widget. + + Args: + start_tracking_widget (StartTrackingBox): Is needed in order to set the raw fact. + controller: Is needed in order to query for recent activities. + """ + super(Gtk.Grid, self).__init__(*args, **kwargs) + self._start_tracking_widget = start_tracking_widget + self._controller = controller + + self._controller.signal_handler.connect('facts-changed', self.refresh) + self._populate() + + def refresh(self, sender=None): + """Clear the current content and re-populate and re-draw the widget.""" + helpers.clear_children(self) + self._populate() + self.show_all() + + def _populate(self): + """Fill the widget with rows per activity.""" + def add_row_widgets(row_index, activity): + """ + Add a set of widgets to a specific row based on the activity passed. + + Args: + row_counter (int): Which row to add to. + activity (hamster_lib.Activity): The activity that is represented by this row. + """ + def get_label(activity): + """Label representing the activity/category combination.""" + label = Gtk.Label(helpers.serialize_activity(activity)) + label.set_halign(Gtk.Align.START) + return label + + def get_copy_button(activity): + """ + A button that will copy the activity/category string to the raw fact entry. + + The main use case for this is a user that want to add a description or tag before + actually starting the tracking. + """ + button = Gtk.Button('Copy') + activity = helpers.serialize_activity(activity) + button.connect('clicked', self._on_copy_button, activity) + return button + + def get_start_button(activity): + """A button that will start a new ongoing fact based on that activity.""" + button = Gtk.Button('Start') + activity = helpers.serialize_activity(activity) + button.connect('clicked', self._on_start_button, activity) + return button + + self.attach(get_label(activity), 0, row_index, 1, 1) + self.attach(get_copy_button(activity), 1, row_index, 1, 1) + self.attach(get_start_button(activity), 2, row_index, 1, 1) + + today = datetime.date.today() + start = today - datetime.timedelta(1) + activities = helpers.get_recent_activities(self._controller, start, today) + + row_index = 0 + for activity in activities: + add_row_widgets(row_index, activity) + row_index += 1 + + def _on_copy_button(self, button, activity): + """ + Set the activity/category text in the 'start tracking entry'. + + Args: + button (Gtk.Button): The button that was clicked. + activity (text_type): Activity text to be copied as raw fact. + + Note: + Besides copying the text we also assign focus and place the cursor + at the end of the pasted text as to facilitate fast entry of + additional text. + """ + self._start_tracking_widget.set_raw_fact(activity) + self._start_tracking_widget.raw_fact_entry.grab_focus_without_selecting() + self._start_tracking_widget.raw_fact_entry.set_position(len(activity)) + + def _on_start_button(self, button, activity): + """ + Start a new ongoing fact based on this activity/category. + + Args: + button (Gtk.Button): The button that was clicked. + activity (text_type): Activity text to be copied as raw fact. + """ + self._start_tracking_widget.set_raw_fact(activity) + self._start_tracking_widget._start_ongoing_fact() diff --git a/tests/conftest.py b/tests/conftest.py index b4eb30d2..3318de9f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,13 +35,17 @@ def appdirs(request): # Instances @pytest.fixture -def app(request): +def app(request, config): """ Return an ``Application`` fixture. Please note: the app has just been started but not activated. """ - app = hamster_gtk.HamsterGTK() + def monkeypatched_reload_config(self): + return config + HamsterGTK = hamster_gtk.HamsterGTK # NOQA + HamsterGTK._reload_config = monkeypatched_reload_config + app = HamsterGTK() app._startup(app) return app @@ -104,10 +108,12 @@ def config(request, tmpdir): 'store': 'sqlalchemy', 'day_start': datetime.time(5, 30, 0), 'fact_min_delta': 1, - 'tmpfile_path': tmpdir.join('tmpfile.hamster'), + 'tmpfile_path': str(tmpdir.join('tmpfile.hamster')), 'db_engine': 'sqlite', 'db_path': ':memory:', 'autocomplete_activities_range': 30, 'autocomplete_split_activity': False, + 'tracking_show_recent_activities': True, + 'tracking_recent_activities_count': 6, } return config diff --git a/tests/misc/widgets/test_raw_fact_completion.py b/tests/misc/widgets/test_raw_fact_completion.py index 977fc82d..4b24388b 100644 --- a/tests/misc/widgets/test_raw_fact_completion.py +++ b/tests/misc/widgets/test_raw_fact_completion.py @@ -20,9 +20,8 @@ from __future__ import absolute_import, unicode_literals -from orderedset import OrderedSet - from gi.repository import Gtk +from orderedset import OrderedSet from hamster_gtk.misc.widgets.raw_fact_entry import RawFactCompletion diff --git a/tests/preferences/conftest.py b/tests/preferences/conftest.py index 533ce09a..dda65adc 100644 --- a/tests/preferences/conftest.py +++ b/tests/preferences/conftest.py @@ -80,11 +80,24 @@ def autocomplete_split_activity_parametrized(request): return request.param +@pytest.fixture(params=(True, False)) +def tracking_show_recent_activities_parametrized(request): + """Return a parametrized tracking_show_recent_activities_parametrized value.""" + return request.param + + +@pytest.fixture(params=(0, 1, 5, 15)) +def tracking_recent_activities_count_parametrized(request): + """Return a parametrized tracking_recent_activities_items_parametrized value.""" + return request.param + + @pytest.fixture def config_parametrized(request, store_parametrized, day_start_parametrized, fact_min_delta_parametrized, tmpfile_path_parametrized, db_engine_parametrized, db_path_parametrized, autocomplete_activities_range_parametrized, - autocomplete_split_activity_parametrized): + autocomplete_split_activity_parametrized, tracking_show_recent_activities_parametrized, + tracking_recent_activities_count_parametrized): """Return a config fixture with heavily parametrized config values.""" return { 'store': store_parametrized, @@ -95,6 +108,8 @@ def config_parametrized(request, store_parametrized, day_start_parametrized, 'db_path': db_path_parametrized, 'autocomplete_activities_range': autocomplete_activities_range_parametrized, 'autocomplete_split_activity': autocomplete_split_activity_parametrized, + 'tracking_show_recent_activities': tracking_show_recent_activities_parametrized, + 'tracking_recent_activities_count': tracking_recent_activities_count_parametrized, } diff --git a/tests/preferences/test_preferences_dialog.py b/tests/preferences/test_preferences_dialog.py index 7582c380..b920d356 100644 --- a/tests/preferences/test_preferences_dialog.py +++ b/tests/preferences/test_preferences_dialog.py @@ -19,8 +19,9 @@ def test_init(self, dummy_window, app, config, empty_initial): grids = result.get_content_area().get_children()[0].get_children() # This assumes 2 children per config entry (label and widget). grid_entry_counts = [len(g.get_children()) / 2 for g in grids] - assert sum(grid_entry_counts) == 8 + assert sum(grid_entry_counts) == 10 + @pytest.mark.slowtest def test_get_config(self, preferences_dialog, config_parametrized): """ Make sure retrieval of field values works as expected. @@ -32,6 +33,7 @@ def test_get_config(self, preferences_dialog, config_parametrized): result = preferences_dialog.get_config() assert result == config_parametrized + @pytest.mark.slowtest def test_set_config(self, preferences_dialog, config_parametrized): """Make sure setting the field values works as expected.""" preferences_dialog._set_config(config_parametrized) diff --git a/tests/test_hamster-gtk.py b/tests/test_hamster-gtk.py index 1df583a5..1e7b8f5a 100755 --- a/tests/test_hamster-gtk.py +++ b/tests/test_hamster-gtk.py @@ -14,9 +14,19 @@ class TestHamsterGTK(object): """Unittests for the main app class.""" - def test_instantiation(self): - """Make sure class instatiation works as intended.""" - app = hamster_gtk.HamsterGTK() + def test_instantiation(self, config): + """ + Make sure class instantiation works as intended. + + We actually test against a monkeypatched class in order to avoid the + config loading machinery as this would access the user data on fs. + The relevant skiped methods need to be tested separately. + """ + def monkeypatched_reload_config(self): + return config + HamsterGTK = hamster_gtk.HamsterGTK # NOQA + HamsterGTK._reload_config = monkeypatched_reload_config + app = HamsterGTK() assert app def test__reload_config(self, app, config, mocker): @@ -29,7 +39,7 @@ def test__reload_config(self, app, config, mocker): def test__get_default_config(self, app, appdirs): """Make sure the defaults use appdirs for relevant paths.""" result = app._get_default_config() - assert len(result) == 8 + assert len(result) == 10 assert os.path.dirname(result['tmpfile_path']) == appdirs.user_data_dir assert os.path.dirname(result['db_path']) == appdirs.user_data_dir diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 20da0a31..49b6cfef 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- -import datetime +from __future__ import unicode_literals -from gi.repository import Gtk +import datetime import pytest +from gi.repository import Gtk import hamster_gtk.helpers as helpers @@ -145,3 +146,26 @@ def test__get_delta_string(minutes, expectation): delta = datetime.timedelta(minutes=minutes) result = helpers.get_delta_string(delta) assert result == expectation + + +class TestSerializeActivity(object): + """Unit tests for `serialize_activity` helper function.""" + + #@pytest.mark.parametrize('seperator', ( + def test_with_category(self, activity): + """Make sure that the serialized activity matches expectations.""" + result = helpers.serialize_activity(activity) + assert result == '{s.name}@{s.category.name}'.format(s=activity) + + @pytest.mark.parametrize('activity__category', (None,)) + def test_without_category(self, activity): + """Make sure that the serialized activity matches expectations.""" + result = helpers.serialize_activity(activity) + assert result == '{s.name}'.format(s=activity) + + @pytest.mark.parametrize('separator', (';', '/', '%')) + def test_seperators(self, activity, separator): + """Make sure that the serialized activity matches expectations.""" + result = helpers.serialize_activity(activity, separator) + assert result == '{s.name}{seperator}{s.category.name}'.format(s=activity, + seperator=separator) diff --git a/tests/tracking/test_screens.py b/tests/tracking/test_screens.py index 0e20ebf5..4ac5499c 100644 --- a/tests/tracking/test_screens.py +++ b/tests/tracking/test_screens.py @@ -46,7 +46,7 @@ def test_init(self, app): """Make sure instances matches expectation.""" result = screens.StartTrackingBox(app) assert isinstance(result, screens.StartTrackingBox) - assert len(result.get_children()) == 3 + assert len(result.get_children()) == 4 def test__on_start_tracking_button(self, start_tracking_box, fact, mocker): """Make sure a new 'ongoing fact' is created."""