diff --git a/datashuttle/configs/canonical_configs.py b/datashuttle/configs/canonical_configs.py index b65ad6c66..ce76abf8d 100644 --- a/datashuttle/configs/canonical_configs.py +++ b/datashuttle/configs/canonical_configs.py @@ -242,6 +242,7 @@ def get_tui_config_defaults() -> Dict: "bypass_validation": False, "overwrite_existing_files": "never", "dry_run": False, + "suggest_next_sub_ses_central": False, } } diff --git a/datashuttle/datashuttle_class.py b/datashuttle/datashuttle_class.py index 9322323fa..b9026dd60 100644 --- a/datashuttle/datashuttle_class.py +++ b/datashuttle/datashuttle_class.py @@ -1547,7 +1547,11 @@ def _update_settings_with_new_canonical_keys(self, settings: Dict): if "tui" not in settings: settings.update(canonical_tui_configs) - for key in ["overwrite_existing_files", "dry_run"]: + for key in [ + "overwrite_existing_files", + "dry_run", + "suggest_next_sub_ses_central", + ]: if key not in settings["tui"]: settings["tui"][key] = canonical_tui_configs["tui"][key] diff --git a/datashuttle/tui/app.py b/datashuttle/tui/app.py index 411f9ef8c..022d74f9d 100644 --- a/datashuttle/tui/app.py +++ b/datashuttle/tui/app.py @@ -115,6 +115,13 @@ def load_project_page(self, interface: Interface) -> None: def show_modal_error_dialog(self, message: str) -> None: self.push_screen(modal_dialogs.MessageBox(message, border_color="red")) + def show_modal_error_dialog_from_main_thread(self, message: str) -> None: + """ + Used to call `show_modal_error_dialog from main thread when executing + in another thread. Throws error when called from main thread. + """ + self.call_from_thread(self.show_modal_error_dialog, message) + def handle_open_filesystem_browser(self, path_: Path) -> None: """ Open the system file browser to the path with the `showinfm` diff --git a/datashuttle/tui/css/tui_menu.tcss b/datashuttle/tui/css/tui_menu.tcss index 00cb9458a..de52087b4 100644 --- a/datashuttle/tui/css/tui_menu.tcss +++ b/datashuttle/tui/css/tui_menu.tcss @@ -313,9 +313,16 @@ CreateFoldersSettingsScreen { width: 80%; background: $primary-background; border: tall $panel-lighten-3; - } +} +#checkbox_container { + height: 65%; + overflow: hidden auto; +} +#toplevel_folder_select_container { + height: 15%; +} #template_top_container { - height: 50%; + height: 70%; background: $primary-background; border: tall $panel-lighten-3; overflow: hidden auto; @@ -436,6 +443,41 @@ ConfirmAndAwaitTransferPopup { content-align: center middle; } +/* Suggest next subject / session loading pop up --------------------------------------------------- */ + +SearchingCentralForNextSubSesPopup { + align: center middle; +} + +#searching_top_container { + align: center middle; + height: 15; + width: 65; + border: tall $panel-lighten-1; + background: $primary-background; + } + +#searching_top_container:light { + background: $boost; + border: tall $panel-darken-3; +} + +#searching_message_label { + align: center middle; + text-align: center; + overflow: hidden auto; + width: 100%; + margin: 1 0 0 0; +} + +#searching_animated_indicator { + align: center middle; + padding: 0; + height: 3; + margin: 1 0 0 0; + content-align: center middle; +} + /* Light Mode Error Screen */ MessageBox:light > #confirm_top_container { diff --git a/datashuttle/tui/interface.py b/datashuttle/tui/interface.py index e9520bb03..aedd2815e 100644 --- a/datashuttle/tui/interface.py +++ b/datashuttle/tui/interface.py @@ -432,20 +432,20 @@ def get_textual_compatible_project_configs(self) -> Configs: return cfg_to_load def get_next_sub( - self, top_level_folder: TopLevelFolder + self, top_level_folder: TopLevelFolder, include_central: bool ) -> InterfaceOutput: try: next_sub = self.project.get_next_sub( top_level_folder, return_with_prefix=True, - include_central=False, + include_central=include_central, ) return True, next_sub except BaseException as e: return False, str(e) def get_next_ses( - self, top_level_folder: TopLevelFolder, sub: str + self, top_level_folder: TopLevelFolder, sub: str, include_central: bool ) -> InterfaceOutput: try: @@ -453,7 +453,7 @@ def get_next_ses( top_level_folder, sub, return_with_prefix=True, - include_central=False, + include_central=include_central, ) return True, next_ses except BaseException as e: diff --git a/datashuttle/tui/screens/create_folder_settings.py b/datashuttle/tui/screens/create_folder_settings.py index 9c7937221..237ff7187 100644 --- a/datashuttle/tui/screens/create_folder_settings.py +++ b/datashuttle/tui/screens/create_folder_settings.py @@ -86,6 +86,9 @@ def compose(self) -> ComposeResult: """ bypass_validation = self.interface.tui_settings["bypass_validation"] + suggest_next_sub_ses_central = self.interface.tui_settings[ + "suggest_next_sub_ses_central" + ] yield Container( Horizontal( @@ -97,43 +100,52 @@ def compose(self) -> ComposeResult: self.interface, id="create_folders_settings_toplevel_select", ), - ), - Checkbox( - "Bypass validation", - value=bypass_validation, - id="create_folders_settings_bypass_validation_checkbox", + id="toplevel_folder_select_container", ), Container( - Horizontal( - Checkbox( - "Template Validation", - id="template_settings_validation_on_checkbox", - value=self.interface.get_name_templates()["on"], - ), - id="template_inner_horizontal_container", + Checkbox( + "Search Central For Suggestions", + value=suggest_next_sub_ses_central, + id="suggest_next_sub_ses_central_checkbox", + ), + Checkbox( + "Bypass validation", + value=bypass_validation, + id="create_folders_settings_bypass_validation_checkbox", ), Container( - Label(explanation, id="template_message_label"), + Horizontal( + Checkbox( + "Template validation", + id="template_settings_validation_on_checkbox", + value=self.interface.get_name_templates()["on"], + ), + id="template_inner_horizontal_container", + ), Container( - RadioSet( - RadioButton( - "Subject", - id="template_settings_subject_radiobutton", - value=sub_on, - ), - RadioButton( - "Session", - id="template_settings_session_radiobutton", - value=ses_on, + Label(explanation, id="template_message_label"), + Container( + RadioSet( + RadioButton( + "Subject", + id="template_settings_subject_radiobutton", + value=sub_on, + ), + RadioButton( + "Session", + id="template_settings_session_radiobutton", + value=ses_on, + ), + id="template_settings_radioset", ), - id="template_settings_radioset", + Input(id="template_settings_input"), + id="template_other_widgets_container", ), - Input(id="template_settings_input"), - id="template_other_widgets_container", + id="template_inner_container", ), - id="template_inner_container", + id="template_top_container", ), - id="template_top_container", + id="checkbox_container", ), Container(), Button("Close", id="create_folders_settings_close_button"), @@ -145,6 +157,7 @@ def on_mount(self) -> None: "#create_folders_settings_toplevel_select", "#create_folders_settings_bypass_validation_checkbox", "#template_settings_validation_on_checkbox", + "#suggest_next_sub_ses_central_checkbox", ]: self.query_one(id).tooltip = get_tooltip(id) @@ -235,6 +248,10 @@ def on_checkbox_changed(self, event: Checkbox.Changed) -> None: self.query_one("#template_inner_container").disabled = ( disable_container ) + elif event.checkbox.id == "suggest_next_sub_ses_central_checkbox": + self.interface.save_tui_settings( + is_on, "suggest_next_sub_ses_central" + ) def on_radio_set_changed(self, event: RadioSet.Changed) -> None: """ diff --git a/datashuttle/tui/screens/modal_dialogs.py b/datashuttle/tui/screens/modal_dialogs.py index 5bd555692..d2fc3dac7 100644 --- a/datashuttle/tui/screens/modal_dialogs.py +++ b/datashuttle/tui/screens/modal_dialogs.py @@ -7,10 +7,11 @@ from pathlib import Path from textual.app import ComposeResult + from textual.widgets import DirectoryTree from textual.worker import Worker from datashuttle.tui.app import TuiApp - from datashuttle.utils.custom_types import InterfaceOutput + from datashuttle.utils.custom_types import InterfaceOutput, Prefix from pathlib import Path @@ -19,7 +20,10 @@ from textual.widgets import Button, Input, Label, LoadingIndicator, Static from datashuttle.tui.custom_widgets import CustomDirectoryTree -from datashuttle.tui.utils.tui_decorators import require_double_click +from datashuttle.tui.utils.tui_decorators import ( + ClickInfo, + require_double_click, +) class MessageBox(ModalScreen): @@ -137,6 +141,27 @@ async def handle_transfer_and_update_ui_when_complete(self) -> None: self.app.show_modal_error_dialog(output) +class SearchingCentralForNextSubSesPopup(ModalScreen): + """ + A popup to show message and a loading indicator when awaiting search next sub/ses across + the folders present in both local and central machines. This search happens in a separate + thread so as to allow TUI to display the loading indicate without freezing. + + Only displayed when the `include_central` flag is checked and the connection method is "ssh". + """ + + def __init__(self, sub_or_ses: Prefix) -> None: + super().__init__() + self.message = f"Searching central for next {sub_or_ses}" + + def compose(self) -> ComposeResult: + yield Container( + Label(self.message, id="searching_message_label"), + LoadingIndicator(id="searching_animated_indicator"), + id="searching_top_container", + ) + + class SelectDirectoryTreeScreen(ModalScreen): """ A modal screen that includes a DirectoryTree to browse @@ -165,7 +190,7 @@ def __init__( path_ = Path().home() self.path_ = path_ - self.prev_click_time = 0 + self.click_info = ClickInfo() def compose(self) -> ComposeResult: @@ -186,11 +211,13 @@ def compose(self) -> ComposeResult: ) @require_double_click - def on_directory_tree_directory_selected(self, node) -> None: - if node.path.is_file(): + def on_directory_tree_directory_selected( + self, event: DirectoryTree.DirectorySelected + ) -> None: + if event.path.is_file(): return else: - self.dismiss(node.path) + self.dismiss(event.path) def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "cancel_button": diff --git a/datashuttle/tui/tabs/create_folders.py b/datashuttle/tui/tabs/create_folders.py index bbbc50343..036a61dec 100644 --- a/datashuttle/tui/tabs/create_folders.py +++ b/datashuttle/tui/tabs/create_folders.py @@ -1,16 +1,19 @@ from __future__ import annotations +import asyncio from typing import TYPE_CHECKING, List, Optional if TYPE_CHECKING: from pathlib import Path from textual.app import ComposeResult + from textual.worker import Worker from datashuttle.tui.app import TuiApp from datashuttle.tui.interface import Interface from datashuttle.utils.custom_types import Prefix +from textual import work from textual.containers import Container, Horizontal from textual.widgets import ( Button, @@ -29,8 +32,14 @@ DatatypeCheckboxes, DisplayedDatatypesScreen, ) +from datashuttle.tui.screens.modal_dialogs import ( + SearchingCentralForNextSubSesPopup, +) from datashuttle.tui.tooltips import get_tooltip -from datashuttle.tui.utils.tui_decorators import require_double_click +from datashuttle.tui.utils.tui_decorators import ( + ClickInfo, + require_double_click, +) from datashuttle.tui.utils.tui_validators import NeuroBlueprintValidator @@ -45,8 +54,11 @@ def __init__(self, mainwindow: TuiApp, interface: Interface) -> None: ) self.mainwindow = mainwindow self.interface = interface + self.searching_central_popup_widget: ( + SearchingCentralForNextSubSesPopup | None + ) = None - self.prev_click_time = 0.0 + self.click_info = ClickInfo() def compose(self) -> ComposeResult: yield CustomDirectoryTree( @@ -156,7 +168,11 @@ def on_clickable_input_clicked( if event.ctrl: self.fill_input_with_template(prefix, input_id) else: - self.fill_input_with_next_sub_or_ses_template(prefix, input_id) + include_central = self.interface.get_tui_settings()[ + "suggest_next_sub_ses_central" + ] + + self.suggest_next_sub_ses(prefix, input_id, include_central) def on_custom_directory_tree_directory_tree_special_key_press( self, event: CustomDirectoryTree.DirectoryTreeSpecialKeyPress @@ -272,9 +288,62 @@ def reload_directorytree(self) -> None: # Filling Inputs # ---------------------------------------------------------------------------------- + def suggest_next_sub_ses( + self, prefix: Prefix, input_id: str, include_central: bool + ): + """ + This handles suggesting next sub/ses for the project. Shows + a pop up screen in cases when searching for next sub/ses takes + time such as searching central in SSH connection method. + + Creates an asyncio task which handles the suggestion logic and + dismissing the pop up. + """ + assert self.interface.project.cfg["connection_method"] in [ + None, + "local_filesystem", + "ssh", + ] + + if ( + include_central + and self.interface.project.cfg["connection_method"] == "ssh" + ): + self.searching_central_popup_widget = ( + SearchingCentralForNextSubSesPopup(prefix) + ) + self.mainwindow.push_screen(self.searching_central_popup_widget) + + asyncio.create_task( + self.fill_suggestion_and_dismiss_popup( + prefix, input_id, include_central + ), + name=f"suggest_next_{prefix}_async_task", + ) + + async def fill_suggestion_and_dismiss_popup( + self, prefix, input_id, include_central + ): + """ + This handles running the `fill_input_with_next_sub_or_ses_template` + worker and waiting for it to complete. If an error occurs in + `fill_input_with_next_sub_or_ses_template`, it dismisses the popup itself. + + Else, if the worker successfully exits, this function handles dismissal + of the popup. + """ + worker = self.fill_input_with_next_sub_or_ses_template( + prefix, input_id, include_central + ) + await worker.wait() + if self.searching_central_popup_widget: + self.searching_central_popup_widget.dismiss() + self.searching_central_popup_widget = None + + @work(exclusive=True, thread=True) def fill_input_with_next_sub_or_ses_template( - self, prefix: Prefix, input_id: str - ) -> None: + self, prefix: Prefix, input_id: str, include_central: bool + ) -> Worker: """ This fills a sub / ses Input with a suggested name based on the next subject / session in the project (local). @@ -284,6 +353,9 @@ def fill_input_with_next_sub_or_ses_template( sub or ses key-value. Otherwise, the sub/ses key-value pair only will be suggested. + It runs in a worker thread so as to allow the TUI to show a loading + animation. + Parameters prefix : Prefix @@ -297,9 +369,13 @@ def fill_input_with_next_sub_or_ses_template( ]["create_tab"] if prefix == "sub": - success, output = self.interface.get_next_sub(top_level_folder) + success, output = self.interface.get_next_sub( + top_level_folder, include_central=include_central + ) if not success: - self.mainwindow.show_modal_error_dialog(output) + self.dismiss_popup_and_show_modal_error_dialog_from_thread( + output + ) return else: next_val = output @@ -309,14 +385,14 @@ def fill_input_with_next_sub_or_ses_template( ).as_names_list() if len(sub_names) > 1: - self.mainwindow.show_modal_error_dialog( + self.dismiss_popup_and_show_modal_error_dialog_from_thread( "Can only suggest next session number when a " "single subject is provided." ) return if sub_names == [""]: - self.mainwindow.show_modal_error_dialog( + self.dismiss_popup_and_show_modal_error_dialog_from_thread( "Must input a subject number before suggesting " "next session number." ) @@ -326,10 +402,12 @@ def fill_input_with_next_sub_or_ses_template( sub = sub_names[0] success, output = self.interface.get_next_ses( - top_level_folder, sub + top_level_folder, sub, include_central=include_central ) if not success: - self.mainwindow.show_modal_error_dialog(output) + self.dismiss_popup_and_show_modal_error_dialog_from_thread( + output + ) return else: next_val = output @@ -343,6 +421,26 @@ def fill_input_with_next_sub_or_ses_template( input = self.query_one(f"#{input_id}") input.value = fill_value + def dismiss_popup_and_show_modal_error_dialog_from_thread( + self, message: str + ) -> None: + """ + This is a utility function that the `fill_input_with_next_sub_or_ses_template` + worker calls to display error dialog an if an error occurs while suggesting + the next sub/ses. Handles the TUI widget manipulation from the main thread + when called from within a worker thread. + """ + if self.searching_central_popup_widget: + self.mainwindow.call_from_thread( + self.searching_central_popup_widget.dismiss + ) + self.searching_central_popup_widget = None + + self.mainwindow.show_modal_error_dialog_from_main_thread(message) + + # Validation + # ---------------------------------------------------------------------------------- + def run_local_validation(self, prefix: Prefix): """ Run validation of the values stored in the diff --git a/datashuttle/tui/tabs/logging.py b/datashuttle/tui/tabs/logging.py index fb45c8269..770387f00 100644 --- a/datashuttle/tui/tabs/logging.py +++ b/datashuttle/tui/tabs/logging.py @@ -1,7 +1,13 @@ +from __future__ import annotations + import os from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from textual import events + from textual.widgets import DirectoryTree -from textual import events from textual.containers import Container, Horizontal from textual.screen import ModalScreen from textual.widgets import Button, Label, RichLog, TabPane @@ -9,7 +15,10 @@ from datashuttle.tui.custom_widgets import ( CustomDirectoryTree, ) -from datashuttle.tui.utils.tui_decorators import require_double_click +from datashuttle.tui.utils.tui_decorators import ( + ClickInfo, + require_double_click, +) class RichLogScreen(ModalScreen): @@ -45,7 +54,7 @@ def __init__(self, title, mainwindow, project, id): # display and functionality are always in sync. self.latest_log_path = None self.update_latest_log_path() - self.prev_click_time = 0 + self.click_info = ClickInfo() def update_latest_log_path(self): logs = list(self.project.get_logging_path().glob("*.log")) @@ -94,15 +103,17 @@ def on_button_pressed(self, event): self.push_rich_log_screen(self.latest_log_path) @require_double_click - def on_directory_tree_file_selected(self, node): - if not node.path.is_file(): + def on_directory_tree_file_selected( + self, event: DirectoryTree.FileSelected + ): + if not event.path.is_file(): self.mainwindow.show_modal_error_dialog( "Log file no longer exists. Refresh the directory tree" "by pressing CTRL and r at the same time." ) return - self.push_rich_log_screen(node.path) + self.push_rich_log_screen(event.path) def push_rich_log_screen(self, log_path): self.mainwindow.push_screen( diff --git a/datashuttle/tui/tooltips.py b/datashuttle/tui/tooltips.py index e8d0c771b..f1e33f910 100644 --- a/datashuttle/tui/tooltips.py +++ b/datashuttle/tui/tooltips.py @@ -127,6 +127,11 @@ def get_tooltip(id: str) -> str: elif id == "#create_folders_settings_toplevel_select": tooltip = "The top-level-folder to create folders in." + elif id == "#suggest_next_sub_ses_central_checkbox": + tooltip = ( + "Search the central project folder when suggesting the next subject or session." + "May be slower compared than searching local only." + ) # bypass validation checkbox elif id == "#create_folders_settings_bypass_validation_checkbox": tooltip = ( diff --git a/datashuttle/tui/utils/tui_decorators.py b/datashuttle/tui/utils/tui_decorators.py index f2a96d41f..9b95735be 100644 --- a/datashuttle/tui/utils/tui_decorators.py +++ b/datashuttle/tui/utils/tui_decorators.py @@ -3,35 +3,76 @@ from functools import wraps from time import monotonic +from textual.widgets import DirectoryTree + +from datashuttle.tui.custom_widgets import ClickableInput + # ----------------------------------------------------------------------------- # Double-click decorator # ----------------------------------------------------------------------------- +class ClickInfo: + """ + A class to hold click-info to checking + double clicks are within the time threshold + and match the widget id. + """ + + def __init__(self): + + self.prev_click_time = 0.0 + self.prev_click_widget_id = "" + + def require_double_click(func): """ A decorator that calls the decorated function on a double click, otherwise will not do anything. Requires the first argument (`self` on the class) to - have the attribute `prev_click_time`). + have the attribute `click_info`). Any class holding a widget + that supports double-clicking must have the attribute + self.click_info = ClickInfo() + + The first (non-self) argument depends on the decorated function, + which is usually widget-specific. Unfortunately, these must be + supported on a case-by-case bases and extended when required. """ @wraps(func) def wrapper(*args, **kwargs): parent_class = args[0] - assert hasattr(parent_class, "prev_click_time"), ( + assert hasattr(parent_class, "click_info"), ( "Decorator must be used on class method where the class as " - "the attribute `prev_click_time`." + "the attribute `self.click_info = ClickInfo()`." ) click_time = monotonic() + event = args[1] + + if isinstance(event, ClickableInput.Clicked): + id = event.input.id + elif isinstance(event, DirectoryTree.FileSelected) or isinstance( + event, DirectoryTree.DirectorySelected + ): + id = event.node.tree.id + else: + raise RuntimeError( + "The message type for the widget you are trying to" + "register clicks on is not supported. Add it to the decorator." + ) - if click_time - parent_class.prev_click_time < 0.5: - parent_class.prev_click_time = click_time + if ( + click_time - parent_class.click_info.prev_click_time < 0.5 + and id == parent_class.click_info.prev_click_widget_id + ): + parent_class.click_info.prev_click_time = click_time + parent_class.click_info.prev_click_widget_id = id return func(*args, **kwargs) - parent_class.prev_click_time = click_time + parent_class.click_info.prev_click_time = click_time + parent_class.click_info.prev_click_widget_id = id return wrapper diff --git a/tests/test_utils.py b/tests/test_utils.py index ad908160e..4d53e9f7b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -695,3 +695,8 @@ def get_task_by_name(name): None, ) return target_task + + +async def await_task_by_name_if_present(name: str) -> None: + if task := get_task_by_name(name): + await task diff --git a/tests/tests_integration/test_settings.py b/tests/tests_integration/test_settings.py index b996a1d02..1f19bede4 100644 --- a/tests/tests_integration/test_settings.py +++ b/tests/tests_integration/test_settings.py @@ -186,6 +186,7 @@ def get_settings_default(self): "bypass_validation": False, "overwrite_existing_files": "never", "dry_run": False, + "suggest_next_sub_ses_central": False, } default_settings["create_checkboxes_on"] = { key: {"on": True, "displayed": True} @@ -227,6 +228,7 @@ def get_settings_changed(self): "bypass_validation": True, "overwrite_existing_files": "always", "dry_run": True, + "suggest_next_sub_ses_central": True, } changed_settings["create_checkboxes_on"] = { diff --git a/tests/tests_tui/test_tui_configs.py b/tests/tests_tui/test_tui_configs.py index 00b30b781..e8f5d0767 100644 --- a/tests/tests_tui/test_tui_configs.py +++ b/tests/tests_tui/test_tui_configs.py @@ -1,5 +1,6 @@ import copy from pathlib import Path +from time import monotonic import pytest import test_utils @@ -316,11 +317,11 @@ async def test_configs_select_path(self, monkeypatch): ) root_path = tree.root.data.path - import time + pilot.app.screen.click_info.prev_click_widget_id = tree.id + pilot.app.screen.click_info.prev_click_time = monotonic() - pilot.app.screen.prev_click_time = time.time() pilot.app.screen.on_directory_tree_directory_selected( - tree.root.data + tree.DirectorySelected(tree.root, root_path) ) await pilot.pause() diff --git a/tests/tests_tui/test_tui_create_folders.py b/tests/tests_tui/test_tui_create_folders.py index 00a408f76..85a06cd5c 100644 --- a/tests/tests_tui/test_tui_create_folders.py +++ b/tests/tests_tui/test_tui_create_folders.py @@ -445,6 +445,9 @@ async def test_name_template_next_sub_or_ses_and_validation( await self.double_click( pilot, "#create_folders_subject_input", control=False ) + await test_utils.await_task_by_name_if_present( + "suggest_next_sub_async_task" + ) assert ( pilot.app.screen.query_one( "#create_folders_subject_input" @@ -458,6 +461,9 @@ async def test_name_template_next_sub_or_ses_and_validation( await self.double_click( pilot, "#create_folders_session_input", control=False ) + await test_utils.await_task_by_name_if_present( + "suggest_next_ses_async_task" + ) assert ( pilot.app.screen.query_one( "#create_folders_session_input" @@ -504,6 +510,9 @@ async def test_get_next_sub_and_ses_no_template(self, setup_project_paths): # Double click without CTRL modifier key. await self.double_click(pilot, "#create_folders_subject_input") + await test_utils.await_task_by_name_if_present( + "suggest_next_sub_async_task" + ) assert ( pilot.app.screen.query_one( "#create_folders_subject_input" @@ -515,6 +524,9 @@ async def test_get_next_sub_and_ses_no_template(self, setup_project_paths): pilot, "#create_folders_subject_input", "sub-001" ) await self.double_click(pilot, "#create_folders_session_input") + await test_utils.await_task_by_name_if_present( + "suggest_next_ses_async_task" + ) assert ( pilot.app.screen.query_one( "#create_folders_session_input" @@ -524,6 +536,102 @@ async def test_get_next_sub_and_ses_no_template(self, setup_project_paths): await pilot.pause() + @pytest.mark.asyncio + async def test_get_next_sub_and_ses_central_no_template( + self, setup_project_paths, mocker + ): + """ + Test getting the next subject / session with the include_central option. Check the + checkbox widget that turns the setting on. Trigger a get next subject / session and mock + the underlying datashuttle function to ensure include_central is properly called. + """ + tmp_config_path, tmp_path, project_name = setup_project_paths.values() + + app = TuiApp() + async with app.run_test(size=self.tui_size()) as pilot: + await self.setup_existing_project_create_tab_filled_sub_and_ses( + pilot, project_name, create_folders=True + ) + + # Turn on the central checkbox + await self.scroll_to_click_pause( + pilot, "#create_folders_settings_button" + ) + await self.scroll_to_click_pause( + pilot, "#suggest_next_sub_ses_central_checkbox" + ) + await self.scroll_to_click_pause( + pilot, "#create_folders_settings_close_button" + ) + + # Mock the datashuttle functions + spy_get_next_sub = mocker.spy( + pilot.app.screen.interface.project, "get_next_sub" + ) + spy_get_next_ses = mocker.spy( + pilot.app.screen.interface.project, "get_next_ses" + ) + + # Check subject suggestion called mocked function correctly + await self.double_click(pilot, "#create_folders_subject_input") + await test_utils.await_task_by_name_if_present( + "suggest_next_sub_async_task" + ) + + spy_get_next_sub.assert_called_with( + "rawdata", return_with_prefix=True, include_central=True + ) + + # Check session suggestion called mocked function correctly + await self.fill_input( + pilot, "#create_folders_subject_input", "sub-001" + ) + await self.double_click(pilot, "#create_folders_session_input") + + await test_utils.await_task_by_name_if_present( + "suggest_next_ses_async_task" + ) + + spy_get_next_ses.assert_called_with( + "rawdata", + "sub-001", + return_with_prefix=True, + include_central=True, + ) + + @pytest.mark.asyncio + async def test_get_next_sub_and_ses_error_popup(self, setup_project_paths): + """ + Test the modal error dialog display on encountering an error + while suggesting next sub/ses. Since getting the suggestion happens + in a thread, the `dismiss_popup_and_show_modal_error_dialog_from_thread` + function which is used to display the modal error dialog from main thread + is being tested. It is done by trying to get next session suggestion without + inputting a subject. + """ + tmp_config_path, tmp_path, project_name = setup_project_paths.values() + + app = TuiApp() + async with app.run_test(size=self.tui_size()) as pilot: + await self.setup_existing_project_create_tab_filled_sub_and_ses( + pilot, project_name, create_folders=True + ) + + # Clear the subject input + await self.fill_input(pilot, "#create_folders_subject_input", "") + + await self.double_click(pilot, "#create_folders_session_input") + await test_utils.await_task_by_name_if_present( + "suggest_next_ses_async_task" + ) + + assert ( + "Must input a subject number before suggesting next session number." + in pilot.app.screen.query_one( + "#messagebox_message_label" + ).renderable + ) + # ------------------------------------------------------------------------- # Test Top Level Folders # ------------------------------------------------------------------------- diff --git a/tests/tests_tui/test_tui_widgets_and_defaults.py b/tests/tests_tui/test_tui_widgets_and_defaults.py index a07092962..73f06a22a 100644 --- a/tests/tests_tui/test_tui_widgets_and_defaults.py +++ b/tests/tests_tui/test_tui_widgets_and_defaults.py @@ -399,6 +399,21 @@ async def test_create_folder_settings_widgets(self, setup_project_paths): == "rawdata" ) + # Search central for suggestions checkbox + assert ( + pilot.app.screen.query_one( + "#suggest_next_sub_ses_central_checkbox" + ).label._text + == "Search Central For Suggestions" + ) + assert ( + pilot.app.screen.query_one( + "#suggest_next_sub_ses_central_checkbox" + ).value + is False + ) + + # Bypass validation checkbox assert ( pilot.app.screen.query_one( "#create_folders_settings_bypass_validation_checkbox" @@ -412,11 +427,12 @@ async def test_create_folder_settings_widgets(self, setup_project_paths): is False ) + # Template validation assert ( pilot.app.screen.query_one( "#template_settings_validation_on_checkbox" ).label._text - == "Template Validation" + == "Template validation" ) assert ( pilot.app.screen.query_one( @@ -909,6 +925,87 @@ async def check_top_folder_select( == expected_val ) + @pytest.mark.asyncio + async def test_search_central_for_suggestion_settings( + self, setup_project_paths + ): + """ + Check the settings for the checkbox that selects include_central when + getting the next subject or session in the 'Create' tab and ensure that + the underlying settings are changed. + """ + tmp_config_path, tmp_path, project_name = setup_project_paths.values() + + app = TuiApp() + async with app.run_test(size=self.tui_size()) as pilot: + + await self.setup_existing_project_create_tab_filled_sub_and_ses( + pilot, project_name, create_folders=False + ) + + await self.scroll_to_click_pause( + pilot, "#create_folders_settings_button" + ) + + # Check default value + assert ( + pilot.app.screen.query_one( + "#suggest_next_sub_ses_central_checkbox" + ).value + is False + ) + assert ( + pilot.app.screen.interface.tui_settings[ + "suggest_next_sub_ses_central" + ] + is False + ) + + # Click and check the value is switched + await self.scroll_to_click_pause( + pilot, "#suggest_next_sub_ses_central_checkbox" + ) + + assert ( + pilot.app.screen.query_one( + "#suggest_next_sub_ses_central_checkbox" + ).value + is True + ) + assert ( + pilot.app.screen.interface.tui_settings[ + "suggest_next_sub_ses_central" + ] + is True + ) + + # Refresh the session + await self.scroll_to_click_pause( + pilot, "#create_folders_settings_close_button" + ) + await self.exit_to_main_menu_and_reeneter_project_manager( + pilot, project_name + ) + await self.scroll_to_click_pause( + pilot, "#create_folders_settings_button" + ) + + # Ensure settings persist + assert ( + pilot.app.screen.query_one( + "#suggest_next_sub_ses_central_checkbox" + ).value + is True + ) + assert ( + pilot.app.screen.interface.tui_settings[ + "suggest_next_sub_ses_central" + ] + is True + ) + + await pilot.pause() + @pytest.mark.asyncio async def test_all_checkboxes(self, setup_project_paths): """