Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unit tests for main window actions #293

Merged
merged 26 commits into from
Dec 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
970fce7
DOC: comments on planned refactoring of actions
jotelha Nov 13, 2023
5b809fd
WIP:TST
ashdriod Nov 20, 2023
49214e8
Update main.py
ashdriod Nov 20, 2023
1d0c21c
ENH: add main_window property to app
jotelha Nov 20, 2023
59939d0
MAINT: disable copy manager for now
jotelha Nov 20, 2023
47e6d61
TST: added start-copy action to actions list
jotelha Nov 20, 2023
f72f54f
TST: moved main window fixture to conftest.py
jotelha Nov 20, 2023
485ac54
WIP: await sleep stub
jotelha Nov 20, 2023
2410587
TST: removed main_window fixture
jotelha Nov 20, 2023
9f7c88e
TST: refresh-view test working
jotelha Nov 20, 2023
45622d3
Working GTK Main Window
ashdriod Nov 26, 2023
2b30513
Working GTK Main Window
ashdriod Nov 26, 2023
027ade7
Working GTK Main Window
ashdriod Nov 27, 2023
a1c89fb
Action Testing
ashdriod Nov 27, 2023
6543b91
TST: test action two ways
jotelha Nov 27, 2023
1521a2c
Merge branch '2023-11-13-unit-tests' into HEAD
jotelha Nov 27, 2023
509f9cf
TST: some comments on how unit tests could look like
jotelha Nov 27, 2023
aad4d8e
Merge remote-tracking branch 'origin/2023-11-13-unit-tests' into 2023…
ashdriod Nov 27, 2023
2bf5656
Actual call working
ashdriod Nov 30, 2023
16c6963
TST:WIP
ashdriod Nov 30, 2023
ca90ce2
TST:Comments Added
ashdriod Nov 30, 2023
5ad23f0
Action Testing
ashdriod Dec 3, 2023
8e1bd13
Action Testing
ashdriod Dec 3, 2023
ebdd23a
ENH:TST
ashdriod Dec 4, 2023
68c037b
MAINT: pagination information display more resilient
jotelha Dec 4, 2023
161a8ca
TST: on the way to proper testing against a populated app
jotelha Dec 4, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions dtool_lookup_gui/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#
# Copyright 2021-2023 Johannes Laurin Hörmann
# 2023 Ashwin Vazhappilly
# Copyright 2021-2022 Johannes Laurin Hörmann
# 2021 Lars Pastewka
#
# ### MIT license
Expand Down Expand Up @@ -34,7 +33,6 @@

import dtoolcore
import dtool_lookup_api.core.config

from dtool_lookup_api.core.LookupClient import authenticate

import gi
Expand Down Expand Up @@ -87,6 +85,13 @@ def __init__(self, *args, loop=None, **kwargs):
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE, **kwargs)
self.loop = loop
self.args = None
self._progress_revealer = None
self._progress_popover = None
self._main_window = None

@property
def main_window(self):
return self._main_window

def do_activate(self):
logger.debug("do_activate")
Expand All @@ -105,6 +110,9 @@ def do_activate(self):
logger.debug("Build GUI.")

win = MainWindow(application=self)
self._main_window = win
logger.debug("New main window ID: %s", win.get_id())

glob_pattern = os.path.join(os.path.dirname(__file__), os.pardir, 'data','icons','*','dtool_logo.xpm')
icon_file_list = glob.glob(glob_pattern)
if len(icon_file_list) > 0:
Expand All @@ -115,7 +123,7 @@ def do_activate(self):
else:
logger.warning("Could not load app icons.")
win.connect('destroy', lambda _: self.loop.stop())
self.loop.call_soon(win.refresh) # Populate widgets after event loop starts
#self.loop.call_soon(win.refresh) # Populate widgets after event loop starts

logger.debug("Present main window.")
win.present()
Expand Down Expand Up @@ -234,6 +242,11 @@ def do_startup(self):
renew_token_action.connect("activate", self.do_renew_token)
self.add_action(renew_token_action)

# copy-manager action
copy_action = Gio.SimpleAction.new("start-copy", GLib.VariantType.new("(ss)"))
copy_action.connect("activate", self.do_copy)
self.add_action(copy_action)

Gtk.Application.do_startup(self)

# custom application-scoped actions
Expand Down Expand Up @@ -338,6 +351,43 @@ async def retrieve_token(auth_url, username, password):
username,
password))

async def do_copy(self, action, value):
dataset, destination = value.unpack()

# Ensure that _progress_revealer and _progress_popover are initialized
if not self._progress_revealer or not self._progress_popover:
logger.error("Progress revealer or popover not initialized")
return

self._progress_revealer.set_reveal_child(True)
tracker = self._progress_popover.add_status_box(
self.progress_update, f'Copying dataset "{dataset}" to "{destination}"')

try:
await dataset.copy(destination, progressbar=tracker)
except ChildProcessError as exc:
logger.error(str(exc))
# Handle specific exceptions here
except Exception as e:
logger.error(f"Unexpected error: {e}")
# Handle other unexpected errors

tracker.set_done()
self.check_all_operations_done()

def progress_update(self):
if not self._progress_popover or not self._progress_chart:
logger.error("Progress popover or chart not initialized")
return

total_length = sum([len(tracker) for tracker in self._progress_popover.status_boxes])
total_step = sum([tracker.step for tracker in self._progress_popover.status_boxes])

if total_length > 0:
self._progress_chart.set_fraction(total_step / total_length)
else:
self._progress_chart.set_fraction(1.0)


def run_gui():
GObject.type_register(GtkSource.View)
Expand Down
6 changes: 6 additions & 0 deletions dtool_lookup_gui/utils/copy_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,16 @@ class CopyManager:

def __init__(self, progress_revealer, progress_popover):
# Note: This is not particularly abstract, as it interacts directly with the Gtk widgets

# TODO: instead of a single progress_revealer and progress_popover assigned at instantiation,
# the copy manager would have an arbitrary amount of those that can be dynamically attached and detached

self._progress_revealer = progress_revealer
self._progress_chart = progress_revealer.get_child().get_child()
self._progress_popover = progress_popover

async def copy(self, dataset, destination):
# TODO: here the copy manager would loop over all attached status widgets
self._progress_revealer.set_reveal_child(True)
tracker = self._progress_popover.add_status_box(
self.progress_update, f'Copying dataset »{dataset}« to »{destination}«')
Expand All @@ -61,6 +66,7 @@ async def copy(self, dataset, destination):
self._progress_revealer.set_reveal_child(False)

def progress_update(self):
# TODO: here the copy manager would loop over all attached status widgets
# Refresh pie chart
total_length = sum([len(tracker) for tracker in self._progress_popover.status_boxes])
total_step = sum([tracker.step for tracker in self._progress_popover.status_boxes])
Expand Down
53 changes: 40 additions & 13 deletions dtool_lookup_gui/views/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
# SOFTWARE.
#

# TODO: any atomic operations that reflect core dtool behavior as documented
# on https://dtoolcore.readthedocs.io/en/latest/api/dtoolcore.html#dtoolcore.DataSetCreator
# or https://dtoolcore.readthedocs.io/en/latest/api/dtoolcore.html#dtoolcore.ProtoDataSet
# should move to atomic actions on the main app level

import asyncio
import logging
import os
Expand Down Expand Up @@ -178,8 +183,6 @@ class MainWindow(Gtk.ApplicationWindow):
contents_per_page = Gtk.Template.Child()
linting_errors_button = Gtk.Template.Child()



def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

Expand All @@ -198,8 +201,7 @@ def __init__(self, *args, **kwargs):

self.readme_buffer.connect("changed", self.on_readme_buffer_changed)

self.error_bar.set_revealed(False)
self.progress_revealer.set_reveal_child(False)


# connect log handler to error bar
root_logger = logging.getLogger()
Expand All @@ -218,6 +220,13 @@ def __init__(self, *args, **kwargs):
self.server_versions_dialog = ServerVersionsDialog(application=self.application)
self.error_linting_dialog = LintingErrorsDialog(application=self.application)

# Initialize the progress revealer and popover
self.progress_revealer = None # Replace this with actual initialization
self.progress_popover = None # Replace this with actual initialization

# Initialize the CopyManager
# self._copy_manager = CopyManager(self.progress_revealer, self.progress_popover)

# window-scoped actions

# search action
Expand Down Expand Up @@ -269,7 +278,14 @@ def __init__(self, *args, **kwargs):

self.dependency_graph_widget.search_by_uuid = self._search_by_uuid

self._copy_manager = CopyManager(self.progress_revealer, self.progress_popover)
# TODO: is it possible to attach the CopyManager to the main app instead of this main window?
# One possibility would be to refactor the CopyManager in a way that it can have any
# number of arbitrary progress_revealer and progress_popover components attached.
# The copy manager lives at the Application scope.
# The main window here attaches its own progress_revealer and progress_popover to the
# CopyManager of the application instead of instantiating the copy manager here.
self.get_action_group("app").activate_action("start-copy-manager", None)


_logger.debug(f"Constructed main window for app '{self.application.get_application_id()}'")

Expand Down Expand Up @@ -336,15 +352,23 @@ async def _refresh_base_uri_list_box(self):
def _update_search_summary(self, datasets):
row = self.base_uri_list_box.search_results_row
total_size = sum([0 if dataset.size_int is None else dataset.size_int for dataset in datasets])
total_value = self.pagination['total']
if hasattr(self.pagination, 'total'):
total_value = self.pagination['total']
else:
_logger.warning("No pagination information available.")
total_value = len(datasets)

row.info_label.set_text(f'{total_value} datasets, {sizeof_fmt(total_size).strip()}')

def _update_main_statusbar(self):
total_number = self.pagination['total']
current_page = self.pagination['page']
last_page = self.pagination['last_page']
self.main_statusbar.push(0,
f"Total Number of Datasets: {total_number}, Current Page: {current_page} of {last_page}")
if hasattr(self.pagination, 'total') and hasattr(self.pagination, 'page') and hasattr(self.pagination, 'last_page'):
total_number = self.pagination['total']
current_page = self.pagination['page']
last_page = self.pagination['last_page']
self.main_statusbar.push(0,
f"Total Number of Datasets: {total_number}, Current Page: {current_page} of {last_page}")
else:
_logger.warning("No pagination information available to display in status bar.")

def contents_per_page_changed(self, widget):
self.contents_per_page_value = widget.get_active_text()
Expand Down Expand Up @@ -420,7 +444,6 @@ async def retry():
# If a widget was passed in, re-enable it
self.enable_pagination_buttons()


def _search_by_uuid(self, uuid):
search_text = dump_single_line_query_text({"uuid": uuid})
self._search_by_search_text(search_text)
Expand Down Expand Up @@ -775,6 +798,7 @@ def on_add_items_clicked(self, widget):
fpaths = dialog.get_filenames()
for fpath in fpaths:
# uri = urllib.parse.unquote(uri, encoding='utf-8', errors='replace')
# TODO: _add_item should turn into an action on the app level as well
self._add_item(fpath)
elif response == Gtk.ResponseType.CANCEL:
pass
Expand Down Expand Up @@ -844,6 +868,7 @@ def on_linting_errors_button_clicked(self, widget):
else:
pass

# TODO: try to refactor into a do_freeze action in the main app as well
@Gtk.Template.Callback()
def on_freeze_clicked(self, widget):
row = self.dataset_list_box.get_selected_row()
Expand Down Expand Up @@ -984,7 +1009,9 @@ def enable_pagination_buttons(self):
self.page_advancer_button.set_sensitive(True)
self.next_page_advancer_button.set_sensitive(True)

# @Gtk.Template.Callback(), not in .ui
# TODO: this should be an action do_copy
# if it is possible to hand to strings, e.g. source and destination to an action, then this action should
# go to the main app.
def on_copy_clicked(self, widget):
async def _copy():
try:
Expand Down
67 changes: 67 additions & 0 deletions test/conftest.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
import json
import logging
import os
import pytest

from unittest.mock import patch

import gi
gi.require_version('Gtk', '3.0')
gi.require_version('GtkSource', '4')
from gi.repository import GLib, GObject, Gio, Gtk, GtkSource, GdkPixbuf

import asyncio
import gbulb
gbulb.install(gtk=True)

import dtool_lookup_api.core.LookupClient

from dtool_lookup_gui.main import Application
from dtool_lookup_gui.views.main_window import MainWindow


logger = logging.getLogger(__name__)


_HERE = os.path.dirname(os.path.abspath(__file__))
# _ROOT = os.path.join(_HERE, "..")

# ==========================================================================
# fixtures from https://github.com/beeware/gbulb/blob/main/tests/conftest.py
# ==========================================================================
Expand Down Expand Up @@ -76,6 +87,62 @@ def app(gtk_loop):
app = Application(loop=gtk_loop)
yield app

@pytest.fixture(scope="function")
def mock_token(scope="function"):
"""Provide a mock authentication token"""
yield "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTcwMTY4OTU5MywianRpIjoiYTdkN2Y5ZWItZGI3MS00YjExLWFhMzktZGQ2YzgzOTJmOWE4IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6InRlc3R1c2VyIiwibmJmIjoxNzAxNjg5NTkzLCJleHAiOjE3MDE2OTMxOTN9.t9SQ00ecZRc-pspz-Du7321xfWIzgTTFKobkNed1CuQYHvtNrc3vdHYbqCWaYCqZpEVF8RlltldT4Lookx6vNgnW4olpiS2KTZ-X2asMhn7SShDtUJuU54CGeViWzYX_V_Pzckoe_cgjFkOutRvnwy_072Whnmc0TwYojwNqUScAIJRu0pzym84JngloXfdI7r25GcRVNtzsGUl7DDfrIz4aSOeVDAVEXhPjgEatKsvNdVZl1DIJsTZpuI7Jh7ZW1WsyjonqHR0J0kIVQn9imQyLyS9_CtmURBQ3kabx6cxhpx5LADrzLutSu24eA4FyECOdzjJ3SPGb9nIVTEDxQg"


@pytest.fixture(scope="function")
def mock_dataset_list():
"""Provide a mock dataset list"""
with open(os.path.join(_HERE, 'data', 'mock_dataset_search_response.json'), 'r') as f:
dataset_list = json.load(f)

yield dataset_list


@pytest.fixture(scope="function")
def mock_config_info():
"""Provide a mock server config info"""
with open(os.path.join(_HERE, 'data', 'mock_config_info_response.json'), 'r') as f:
config_info = json.load(f)

yield config_info


@pytest.fixture(scope="function")
def app_without_authentication(app, mock_token):
"""Removes authentication from app."""
with patch("dtool_lookup_api.core.LookupClient.authenticate", return_value=mock_token):
yield app


@pytest.fixture(scope="function")
def app_with_mock_dtool_lookup_api_calls(
app_without_authentication, mock_dataset_list, mock_config_info):
"""Replaces lookup api calls with mock methods that return fake lists of datasets."""

# TODO: figure out whether mocked methods work as they should
with (
patch("dtool_lookup_api.core.LookupClient.TokenBasedLookupClient.all", return_value=mock_dataset_list),
patch("dtool_lookup_api.core.LookupClient.TokenBasedLookupClient.search", return_value=mock_dataset_list),
patch("dtool_lookup_api.core.LookupClient.TokenBasedLookupClient.config", return_value=mock_config_info),
patch("dtool_lookup_api.core.LookupClient.ConfigurationBasedLookupClient.has_valid_token", return_value=True)
):

yield app_without_authentication


@pytest.fixture(scope="function")
def populated_app(app_with_mock_dtool_lookup_api_calls):
"""Provides app populated with mock datasets."""

# TODO: figure out how to populate and refresh app with data here before app launched
# app.main_window.activate_action('refresh-view')

yield app_with_mock_dtool_lookup_api_calls


# ================================================================
# fixtures related to https://github.com/pytest-dev/pytest-asyncio
Expand Down
Loading
Loading