diff --git a/brainrender_napari/__init__.py b/brainrender_napari/__init__.py index 5a57962..f1fdfce 100644 --- a/brainrender_napari/__init__.py +++ b/brainrender_napari/__init__.py @@ -1,4 +1,4 @@ __version__ = "0.0.1" -__all__ = "BrainrenderWidget" +__all__ = "BrainrenderWidget", "AtlasVersionManagerWidget" diff --git a/brainrender_napari/atlas_version_manager_widget.py b/brainrender_napari/atlas_version_manager_widget.py new file mode 100644 index 0000000..33535eb --- /dev/null +++ b/brainrender_napari/atlas_version_manager_widget.py @@ -0,0 +1,28 @@ +from qtpy.QtWidgets import QVBoxLayout, QWidget + +from brainrender_napari.utils.brainglobe_logo import header_widget +from brainrender_napari.widgets.atlas_manager_view import AtlasManagerView + + +class AtlasVersionManagerWidget(QWidget): + def __init__(self): + """Instantiates the version manager widget + and sets up coordinating connections""" + super().__init__() + + self.setLayout(QVBoxLayout()) + self.layout().addWidget( + header_widget(tutorial_file_name="update-atlas-napari.html") + ) + + # create widgets + self.atlas_manager_view = AtlasManagerView(parent=self) + self.layout().addWidget(self.atlas_manager_view) + + self.atlas_manager_view.download_atlas_confirmed.connect(self._refresh) + + self.atlas_manager_view.update_atlas_confirmed.connect(self._refresh) + + def _refresh(self) -> None: + # refresh view once an atlas has been downloaded + self.atlas_manager_view = AtlasManagerView(parent=self) diff --git a/brainrender_napari/brainrender_widget.py b/brainrender_napari/brainrender_widget.py index b2e8348..24f1adc 100644 --- a/brainrender_napari/brainrender_widget.py +++ b/brainrender_napari/brainrender_widget.py @@ -21,7 +21,7 @@ NapariAtlasRepresentation, ) from brainrender_napari.utils.brainglobe_logo import header_widget -from brainrender_napari.widgets.atlas_table_view import AtlasTableView +from brainrender_napari.widgets.atlas_viewer_view import AtlasViewerView from brainrender_napari.widgets.structure_view import StructureView @@ -40,10 +40,12 @@ def __init__(self, napari_viewer: Viewer): self._viewer = napari_viewer self.setLayout(QVBoxLayout()) - self.layout().addWidget(header_widget()) + self.layout().addWidget( + header_widget(tutorial_file_name="visualise-atlas-napari.html") + ) # create widgets - self.atlas_table_view = AtlasTableView(parent=self) + self.atlas_table_view = AtlasViewerView(parent=self) self.show_structure_names = QCheckBox() self.show_structure_names.setChecked(False) @@ -79,9 +81,6 @@ def __init__(self, napari_viewer: Viewer): self.layout().addWidget(self.structure_tree_group) # connect atlas view widget signals - self.atlas_table_view.download_atlas_confirmed.connect( - self._on_download_atlas_confirmed - ) self.atlas_table_view.add_atlas_requested.connect( self._on_add_atlas_requested ) @@ -102,11 +101,6 @@ def __init__(self, napari_viewer: Viewer): self._on_add_structure_requested ) - def _on_download_atlas_confirmed(self, atlas_name): - """Ensure structure view is displayed if new atlas downloaded.""" - show_structure_names = self.show_structure_names.isChecked() - self.structure_view.refresh(atlas_name, show_structure_names) - def _on_add_structure_requested(self, structure_name: str): """Add given structure as napari atlas representation""" selected_atlas = BrainGlobeAtlas( diff --git a/brainrender_napari/data_models/atlas_table_model.py b/brainrender_napari/data_models/atlas_table_model.py new file mode 100644 index 0000000..b554fa0 --- /dev/null +++ b/brainrender_napari/data_models/atlas_table_model.py @@ -0,0 +1,96 @@ +from bg_atlasapi.list_atlases import ( + get_all_atlases_lastversions, + get_atlases_lastversions, + get_downloaded_atlases, + get_local_atlas_version, +) +from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt + +from brainrender_napari.utils.load_user_data import ( + read_atlas_metadata_from_file, +) + + +class AtlasTableModel(QAbstractTableModel): + """A table data model for atlases.""" + + def __init__(self): + super().__init__() + self.refresh_data() + + def refresh_data(self) -> None: + """Refresh model data by calling atlas API""" + all_atlases = get_all_atlases_lastversions() + data = [] + for name, latest_version in all_atlases.items(): + if name in get_atlases_lastversions().keys(): + data.append( + [ + name, + self._format_name(name), + get_local_atlas_version(name), + latest_version, + ] + ) + else: + data.append( + [name, self._format_name(name), "n/a", latest_version] + ) + + self._data = data + + def _format_name(self, name: str) -> str: + formatted_name = name.split("_") + formatted_name[0] = formatted_name[0].capitalize() + formatted_name[-1] = f"({formatted_name[-1].split('um')[0]} \u03BCm)" + return " ".join([formatted for formatted in formatted_name]) + + def data(self, index: QModelIndex, role=Qt.DisplayRole): + if role == Qt.DisplayRole: + return self._data[index.row()][index.column()] + if role == Qt.ToolTipRole: + hovered_atlas_name = self._data[index.row()][0] + return AtlasTableModel._get_tooltip_text(hovered_atlas_name) + + def rowCount(self, index: QModelIndex = QModelIndex()): + return len(self._data) + + def columnCount(self, index: QModelIndex = QModelIndex()): + return len(self._data[0]) + + def headerData( + self, section: int, orientation: Qt.Orientation, role: Qt.ItemDataRole + ): + """Customises the horizontal header data of model, + and raises an error if an unexpected column is found.""" + if role == Qt.DisplayRole and orientation == Qt.Orientation.Horizontal: + if section == 0: + return "Raw name" + elif section == 1: + return "Atlas" + elif section == 2: + return "Local version" + elif section == 3: + return "Latest version" + else: + raise ValueError("Unexpected horizontal header value.") + else: + return super().headerData(section, orientation, role) + + @classmethod + def _get_tooltip_text(cls, atlas_name: str): + """Returns the atlas metadata as a formatted string, + as well as instructions on how to interact with the atlas.""" + if atlas_name in get_downloaded_atlases(): + metadata = read_atlas_metadata_from_file(atlas_name) + metadata_as_string = "" + for key, value in metadata.items(): + metadata_as_string += f"{key}:\t{value}\n" + + tooltip_text = f"{atlas_name} (double-click to add to viewer)\ + \n{metadata_as_string}" + elif atlas_name in get_all_atlases_lastversions().keys(): + tooltip_text = f"{atlas_name} (double-click to download)" + else: + raise ValueError("Tooltip text called with invalid atlas name.") + return tooltip_text diff --git a/brainrender_napari/data_models/structure_tree_model.py b/brainrender_napari/data_models/structure_tree_model.py new file mode 100644 index 0000000..12d951b --- /dev/null +++ b/brainrender_napari/data_models/structure_tree_model.py @@ -0,0 +1,144 @@ +from typing import Dict, List + +from bg_atlasapi.structure_tree_util import get_structures_tree +from qtpy.QtCore import QAbstractItemModel, QModelIndex, Qt +from qtpy.QtGui import QStandardItem + + +class StructureTreeItem(QStandardItem): + """A class to hold items in a tree model.""" + + def __init__(self, data, parent=None): + self.parent_item = parent + self.item_data = data + self.child_items = [] + + def appendChild(self, item): + self.child_items.append(item) + + def child(self, row): + return self.child_items[row] + + def childCount(self): + return len(self.child_items) + + def columnCount(self): + return len(self.item_data) + + def data(self, column): + try: + return self.item_data[column] + except IndexError: + return None + + def parent(self): + return self.parent_item + + def row(self): + if self.parent_item: + return self.parent_item.child_items.index(self) + return 0 + + +class StructureTreeModel(QAbstractItemModel): + """Implementation of a read-only QAbstractItemModel to hold + the structure tree information provided by the Atlas API in a Qt Model""" + + def __init__(self, data: List, parent=None): + super().__init__() + self.root_item = StructureTreeItem(data=("acronym", "name", "id")) + self.build_structure_tree(data, self.root_item) + + def build_structure_tree(self, structures: List, root: StructureTreeItem): + """Build the structure tree given a list of structures.""" + tree = get_structures_tree(structures) + structure_id_dict = {} + for structure in structures: + structure_id_dict[structure["id"]] = structure + + inserted_items: Dict[int, StructureTreeItem] = {} + for n_id in tree.expand_tree(): # sorts nodes by default, + # so parents will always be already in the QAbstractItemModel + # before their children + node = tree.get_node(n_id) + acronym = structure_id_dict[node.identifier]["acronym"] + name = structure_id_dict[node.identifier]["name"] + if ( + len(structure_id_dict[node.identifier]["structure_id_path"]) + == 1 + ): + parent_item = root + else: + parent_id = tree.parent(node.identifier).identifier + parent_item = inserted_items[parent_id] + + item = StructureTreeItem( + data=(acronym, name, node.identifier), parent=parent_item + ) + parent_item.appendChild(item) + inserted_items[node.identifier] = item + + def data(self, index: QModelIndex, role=Qt.DisplayRole): + """Provides read-only data for a given index if + intended for display, otherwise None.""" + if not index.isValid(): + return None + + if role != Qt.DisplayRole: + return None + + item = index.internalPointer() + + return item.data(index.column()) + + def rowCount(self, parent: StructureTreeItem): + """Returns the number of rows(i.e. children) of an item""" + if parent.column() > 0: + return 0 + + if not parent.isValid(): + parent_item = self.root_item + else: + parent_item = parent.internalPointer() + + return parent_item.childCount() + + def columnCount(self, parent: StructureTreeItem): + """The number of columns of an item.""" + if parent.isValid(): + return parent.internalPointer().columnCount() + else: + return self.root_item.columnCount() + + def parent(self, index: QModelIndex): + """The first-column index of parent of the item + at a given index. Returns an empty index if the root, + or an invalid index, is passed. + """ + if not index.isValid(): + return QModelIndex() + + child_item = index.internalPointer() + parent_item = child_item.parent() + + if parent_item == self.root_item: + return QModelIndex() + + return self.createIndex(parent_item.row(), 0, parent_item) + + def index(self, row, column, parent=QModelIndex()): + """The index of the item at (row, column) with a given parent. + By default, the given parent is assumed to be the root.""" + if not self.hasIndex(row, column, parent): + return QModelIndex() + + if not parent.isValid(): + parent_item = self.root_item + else: + parent_item = parent.internalPointer() + + child_item = parent_item.child(row) + if child_item: + return self.createIndex(row, column, child_item) + else: + return QModelIndex() diff --git a/brainrender_napari/napari.yaml b/brainrender_napari/napari.yaml index f4415b3..2b87da8 100644 --- a/brainrender_napari/napari.yaml +++ b/brainrender_napari/napari.yaml @@ -5,6 +5,11 @@ contributions: - id: brainrender-napari.make_brainrender_widget python_name: brainrender_napari.brainrender_widget:BrainrenderWidget title: Make Brainrender Napari + - id: brainrender-napari.make_atlas_version_manager_widget + python_name: brainrender_napari.atlas_version_manager_widget:AtlasVersionManagerWidget + title: Make Brainrender Napari widgets: - command: brainrender-napari.make_brainrender_widget display_name: Brainrender + - command: brainrender-napari.make_atlas_version_manager_widget + display_name: Atlas version manager diff --git a/brainrender_napari/utils/brainglobe_logo.py b/brainrender_napari/utils/brainglobe_logo.py index 9705303..047c95b 100644 --- a/brainrender_napari/utils/brainglobe_logo.py +++ b/brainrender_napari/utils/brainglobe_logo.py @@ -13,32 +13,31 @@ <\h1> """ -_docs_links_html = """ -

-

Atlas visualisation

-

Website

-

Tutorial

-

Source

-

Citation

-

For help, hover the cursor over the atlases/regions. -

-""" # noqa: E501 - - -def _docs_links_widget(): - docs_links_widget = QLabel(_docs_links_html) + +def _docs_links_widget(tutorial_file_name: str, parent: QWidget = None): + _docs_links_html = f""" +

+

Atlas visualisation

+

Website

+

Tutorial

+

Source

+

Citation

+

For help, hover the cursor over the atlases/regions. +

+ """ # noqa: E501 + docs_links_widget = QLabel(_docs_links_html, parent=parent) docs_links_widget.setOpenExternalLinks(True) return docs_links_widget -def _logo_widget(): - return QLabel(_logo_html) +def _logo_widget(parent: QWidget = None): + return QLabel(_logo_html, parent=None) -def header_widget(parent: QWidget = None): +def header_widget(tutorial_file_name: str, parent: QWidget = None): box = QGroupBox(parent) box.setFlat(True) box.setLayout(QHBoxLayout()) - box.layout().addWidget(_logo_widget()) - box.layout().addWidget(_docs_links_widget()) + box.layout().addWidget(_logo_widget(parent=box)) + box.layout().addWidget(_docs_links_widget(tutorial_file_name, parent=box)) return box diff --git a/brainrender_napari/widgets/atlas_download_dialog.py b/brainrender_napari/widgets/atlas_manager_dialog.py similarity index 79% rename from brainrender_napari/widgets/atlas_download_dialog.py rename to brainrender_napari/widgets/atlas_manager_dialog.py index d8c39a7..08782f3 100644 --- a/brainrender_napari/widgets/atlas_download_dialog.py +++ b/brainrender_napari/widgets/atlas_manager_dialog.py @@ -8,16 +8,16 @@ ) -class AtlasDownloadDialog(QDialog): - """A modal dialog to ask users to confirm they'd like to download +class AtlasManagerDialog(QDialog): + """A modal dialog to ask users to confirm they'd like to download/update the selected atlas, and warn them that it may be slow. """ - def __init__(self, atlas_name): + def __init__(self, atlas_name: str, action: str) -> None: if atlas_name in get_all_atlases_lastversions().keys(): super().__init__() - self.setWindowTitle(f"Download {atlas_name} Atlas") + self.setWindowTitle(f"{action} {atlas_name} Atlas") self.setModal(True) self.label = QLabel("Are you sure?\n(It may take a while)") @@ -36,5 +36,6 @@ def __init__(self, atlas_name): self.setLayout(layout) else: raise ValueError( - "Download Dialog constructor called with invalid atlas name." + "Atlas manager dialog constructor" + "called with invalid atlas name." ) diff --git a/brainrender_napari/widgets/atlas_manager_view.py b/brainrender_napari/widgets/atlas_manager_view.py new file mode 100644 index 0000000..d24bfa1 --- /dev/null +++ b/brainrender_napari/widgets/atlas_manager_view.py @@ -0,0 +1,90 @@ +"""The purpose of this file is to provide interactive model and view classes +for a table holding atlases. Users interacting with the table can request to +* download an atlas (double-click on row of a not-yet downloaded atlas) +* add annotation and reference images (double-click on row of local atlas) +* add additional references (right-click on a row and select from menu) + +It is designed to be agnostic from the viewer framework by emitting signals +that any interested observers can connect to. +""" +from typing import Callable + +from bg_atlasapi.list_atlases import ( + get_downloaded_atlases, +) +from bg_atlasapi.update_atlases import install_atlas, update_atlas +from napari.qt import thread_worker +from qtpy.QtCore import Signal +from qtpy.QtWidgets import QTableView, QWidget + +from brainrender_napari.data_models.atlas_table_model import AtlasTableModel +from brainrender_napari.widgets.atlas_manager_dialog import AtlasManagerDialog + + +class AtlasManagerView(QTableView): + download_atlas_confirmed = Signal(str) + update_atlas_confirmed = Signal(str) + + def __init__(self, parent: QWidget = None): + """Initialises an atlas table view with latest atlas versions. + + Also responsible for appearance, behaviour on selection, and + setting up signal-slot connections. + """ + super().__init__(parent) + + self.setModel(AtlasTableModel()) + self.setEnabled(True) + self.verticalHeader().hide() + self.resizeColumnsToContents() + + self.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) + self.setSelectionMode(QTableView.SelectionMode.SingleSelection) + + self.doubleClicked.connect(self._on_row_double_clicked) + self.hideColumn(0) # hide raw name + + def _on_row_double_clicked(self): + atlas_name = self.selected_atlas_name() + if atlas_name in get_downloaded_atlases(): + # check if update needed + update_dialog = AtlasManagerDialog(atlas_name, "Update") + update_dialog.ok_button.clicked.connect( + self._on_update_atlas_confirmed + ) + update_dialog.exec() + else: + download_dialog = AtlasManagerDialog(atlas_name, "Download") + download_dialog.ok_button.clicked.connect( + self._on_download_atlas_confirmed + ) + download_dialog.exec() + + def _on_download_atlas_confirmed(self): + """Downloads the currently selected atlas and signals this.""" + atlas_name = self.selected_atlas_name() + worker = self._apply_in_thread(install_atlas, atlas_name) + worker.returned.connect(self.download_atlas_confirmed.emit) + worker.start() + + def _on_update_atlas_confirmed(self): + """Updates the currently selected atlas and signals this.""" + atlas_name = self.selected_atlas_name() + worker = self._apply_in_thread(update_atlas, atlas_name) + worker.returned.connect(self.update_atlas_confirmed.emit) + worker.start() + + def selected_atlas_name(self) -> str: + """A single place to get a valid selected atlas name.""" + selected_index = self.selectionModel().currentIndex() + assert selected_index.isValid() + selected_atlas_name_index = selected_index.siblingAtColumn(0) + selected_atlas_name = self.model().data(selected_atlas_name_index) + return selected_atlas_name + + @thread_worker + def _apply_in_thread(self, apply: Callable, atlas_name: str): + """Calls `apply` on the given atlas in a separate thread.""" + apply(atlas_name) + self.model().refresh_data() + return atlas_name diff --git a/brainrender_napari/widgets/atlas_table_view.py b/brainrender_napari/widgets/atlas_table_view.py deleted file mode 100644 index 2507139..0000000 --- a/brainrender_napari/widgets/atlas_table_view.py +++ /dev/null @@ -1,173 +0,0 @@ -"""The purpose of this file is to provide interactive model and view classes -for a table holding atlases. Users interacting with the table can request to -* download an atlas (double-click on row of a not-yet downloaded atlas) -* add annotation and reference images (double-click on row of local atlas) -* add additional references (right-click on a row and select from menu) - -It is designed to be agnostic from the viewer framework by emitting signals -that any interested observers can connect to. -""" -from bg_atlasapi.list_atlases import ( - get_all_atlases_lastversions, - get_downloaded_atlases, -) -from bg_atlasapi.update_atlases import install_atlas -from napari.qt import thread_worker -from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal -from qtpy.QtWidgets import QMenu, QTableView, QWidget - -from brainrender_napari.utils.load_user_data import ( - read_atlas_metadata_from_file, -) -from brainrender_napari.widgets.atlas_download_dialog import ( - AtlasDownloadDialog, -) - - -class AtlasTableModel(QAbstractTableModel): - """A table data model for atlases.""" - - def __init__(self, data): - super().__init__() - self._data = data - - def data(self, index: QModelIndex, role=Qt.DisplayRole): - if role == Qt.DisplayRole: - return self._data[index.row()][index.column()] - if role == Qt.ToolTipRole: - hovered_atlas_name = self._data[index.row()][0] - return AtlasTableModel._get_tooltip_text(hovered_atlas_name) - - def rowCount(self, index: QModelIndex): - return len(self._data) - - def columnCount(self, index: QModelIndex): - return len(self._data[0]) - - def headerData( - self, section: int, orientation: Qt.Orientation, role: Qt.ItemDataRole - ): - """Customises the horizontal header data of model, - and raises an error if an unexpected column is found.""" - if role == Qt.DisplayRole and orientation == Qt.Orientation.Horizontal: - if section == 0: - return "Atlas name" - elif section == 1: - return "Latest version" - else: - raise ValueError("Unexpected horizontal header value.") - else: - return super().headerData(section, orientation, role) - - @classmethod - def _get_tooltip_text(cls, atlas_name: str): - """Returns the atlas metadata as a formatted string, - as well as instructions on how to interact with the atlas.""" - if atlas_name in get_downloaded_atlases(): - metadata = read_atlas_metadata_from_file(atlas_name) - metadata_as_string = "" - for key, value in metadata.items(): - metadata_as_string += f"{key}:\t{value}\n" - - tooltip_text = f"{atlas_name} (double-click to add to viewer)\ - \n{metadata_as_string}" - elif atlas_name in get_all_atlases_lastversions().keys(): - tooltip_text = f"{atlas_name} (double-click to download)" - else: - raise ValueError("Tooltip text called with invalid atlas name.") - return tooltip_text - - -class AtlasTableView(QTableView): - add_atlas_requested = Signal(str) - download_atlas_confirmed = Signal(str) - additional_reference_requested = Signal(str) - selected_atlas_changed = Signal(str) - - def __init__(self, parent: QWidget = None): - """Initialises an atlas table view with latest atlas versions. - - Also responsible for appearance, behaviour on selection, and - setting up signal-slot connections. - """ - super().__init__(parent) - atlases = get_all_atlases_lastversions() - data = [[name, version] for name, version in atlases.items()] - - self.setModel(AtlasTableModel(data)) - self.setEnabled(True) - self.verticalHeader().hide() - - self.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) - self.setSelectionMode(QTableView.SelectionMode.SingleSelection) - self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.customContextMenuRequested.connect( - self._on_context_menu_requested - ) - - self.doubleClicked.connect(self._on_row_double_clicked) - - self.selectionModel().currentChanged.connect(self._on_current_changed) - - def selected_atlas_name(self): - """A single place to get a valid selected atlas name.""" - selected_index = self.selectionModel().currentIndex() - assert selected_index.isValid() - selected_atlas_name_index = selected_index.siblingAtColumn(0) - selected_atlas_name = self.model().data(selected_atlas_name_index) - return selected_atlas_name - - def _on_context_menu_requested(self, position): - """Returns a context menu with a list of additional references for the - currently selected atlas if the atlas is downloaded and has any. If the - user selects one of the additional references, this is signalled. - """ - selected_atlas_name = self.selected_atlas_name() - if selected_atlas_name in get_downloaded_atlases(): - metadata = read_atlas_metadata_from_file(selected_atlas_name) - if ( - "additional_references" in metadata.keys() - and metadata["additional_references"] - ): - global_position = self.viewport().mapToGlobal(position) - additional_reference_menu = QMenu() - - for additional_reference in metadata["additional_references"]: - additional_reference_menu.addAction(additional_reference) - - selected_item = additional_reference_menu.exec(global_position) - if selected_item: - self.additional_reference_requested.emit( - selected_item.text() - ) - - def _on_row_double_clicked(self): - """Emits add_atlas_requested if the currently - selected atlas is available locally. Asks the user to confirm - they'd like to download the atlas otherwise.""" - atlas_name = self.selected_atlas_name() - if atlas_name in get_downloaded_atlases(): - self.add_atlas_requested.emit(atlas_name) - else: - download_dialog = AtlasDownloadDialog(atlas_name) - download_dialog.ok_button.clicked.connect( - self._on_download_atlas_confirmed - ) - download_dialog.exec() - - def _on_download_atlas_confirmed(self): - """Downloads an atlas and signals that this has happened.""" - atlas_name = self.selected_atlas_name() - worker = self._install_atlas_in_thread(atlas_name) - worker.returned.connect(self.download_atlas_confirmed.emit) - worker.start() - - @thread_worker - def _install_atlas_in_thread(self, atlas_name: str): - """Installs the currently selected atlas in a separate thread.""" - install_atlas(atlas_name) - return atlas_name - - def _on_current_changed(self): - """Emits a signal with the newly selected atlas name""" - self.selected_atlas_changed.emit(self.selected_atlas_name()) diff --git a/brainrender_napari/widgets/atlas_viewer_view.py b/brainrender_napari/widgets/atlas_viewer_view.py new file mode 100644 index 0000000..b69cdf6 --- /dev/null +++ b/brainrender_napari/widgets/atlas_viewer_view.py @@ -0,0 +1,104 @@ +"""The purpose of this file is to provide an interactive table view +to request adding of atlas images. Users interacting it can request to +* add annotation and reference images (double-click on row of local atlas) +* add additional references (right-click on a row and select from menu) + +It is designed to be agnostic from the viewer framework by emitting signals +that interested observers can connect to. +""" +from typing import Tuple + +from bg_atlasapi.list_atlases import ( + get_downloaded_atlases, +) +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import QMenu, QTableView, QWidget + +from brainrender_napari.data_models.atlas_table_model import AtlasTableModel +from brainrender_napari.utils.load_user_data import ( + read_atlas_metadata_from_file, +) + + +class AtlasViewerView(QTableView): + add_atlas_requested = Signal(str) + no_atlas_available = Signal() + additional_reference_requested = Signal(str) + selected_atlas_changed = Signal(str) + + def __init__(self, parent: QWidget = None): + """Initialises a table view with locally available atlas versions. + + Also responsible for appearance, behaviour on selection, and + setting up signal-slot connections. + """ + super().__init__(parent) + + self.setModel(AtlasTableModel()) + + self.setEnabled(True) + self.verticalHeader().hide() + self.resizeColumnsToContents() + + self.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) + self.setSelectionMode(QTableView.SelectionMode.SingleSelection) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect( + self._on_context_menu_requested + ) + + self.doubleClicked.connect(self._on_row_double_clicked) + self.selectionModel().currentChanged.connect(self._on_current_changed) + + for hidden_column in [0, 2, 3]: + # hide raw name(0), local and latest versions(2,3) in this view + self.hideColumn(hidden_column) + + if len(get_downloaded_atlases()) == 0: + self.no_atlas_available.emit() + + # hide not locally available atlases + for row_index in range(self.model().rowCount()): + index = self.model().index(row_index, 0) + if self.model().data(index) not in get_downloaded_atlases(): + self.hideRow(row_index) + + def selected_atlas_name(self) -> str: + """A single place to get a valid selected atlas name.""" + selected_index = self.selectionModel().currentIndex() + assert selected_index.isValid() + selected_atlas_name_index = selected_index.siblingAtColumn(0) + selected_atlas_name = self.model().data(selected_atlas_name_index) + assert selected_atlas_name in get_downloaded_atlases() + return selected_atlas_name + + def _on_context_menu_requested(self, position: Tuple[float]) -> None: + """Returns a context menu with a list of additional references for the + currently selected atlas if the atlas has any. If the user selects one + of the additional references, this is signalled. + """ + selected_atlas_name = self.selected_atlas_name() + metadata = read_atlas_metadata_from_file(selected_atlas_name) + if ( + "additional_references" in metadata.keys() + and metadata["additional_references"] + ): + global_position = self.viewport().mapToGlobal(position) + additional_reference_menu = QMenu() + + for additional_reference in metadata["additional_references"]: + additional_reference_menu.addAction(additional_reference) + + selected_item = additional_reference_menu.exec(global_position) + if selected_item: + self.additional_reference_requested.emit(selected_item.text()) + + def _on_row_double_clicked(self) -> None: + """Emits add_atlas_requested if the currently + selected atlas is available locally.""" + atlas_name = self.selected_atlas_name() + self.add_atlas_requested.emit(atlas_name) + + def _on_current_changed(self) -> None: + """Emits a signal with the newly selected atlas name""" + self.selected_atlas_changed.emit(self.selected_atlas_name()) diff --git a/brainrender_napari/widgets/structure_view.py b/brainrender_napari/widgets/structure_view.py index 757d89b..3c422c6 100644 --- a/brainrender_napari/widgets/structure_view.py +++ b/brainrender_napari/widgets/structure_view.py @@ -2,158 +2,18 @@ model and view classes for the structures that form part of an atlas. The view is only visible if the atlas is downloaded.""" -from typing import Dict, List - from bg_atlasapi.list_atlases import get_downloaded_atlases -from bg_atlasapi.structure_tree_util import get_structures_tree -from qtpy.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal -from qtpy.QtGui import QStandardItem +from qtpy.QtCore import QModelIndex, Signal from qtpy.QtWidgets import QTreeView, QWidget +from brainrender_napari.data_models.structure_tree_model import ( + StructureTreeModel, +) from brainrender_napari.utils.load_user_data import ( read_atlas_structures_from_file, ) -class StructureTreeItem(QStandardItem): - """A class to hold items in a tree model.""" - - def __init__(self, data, parent=None): - self.parent_item = parent - self.item_data = data - self.child_items = [] - - def appendChild(self, item): - self.child_items.append(item) - - def child(self, row): - return self.child_items[row] - - def childCount(self): - return len(self.child_items) - - def columnCount(self): - return len(self.item_data) - - def data(self, column): - try: - return self.item_data[column] - except IndexError: - return None - - def parent(self): - return self.parent_item - - def row(self): - if self.parent_item: - return self.parent_item.child_items.index(self) - return 0 - - -class StructureTreeModel(QAbstractItemModel): - """Implementation of a read-only QAbstractItemModel to hold - the structure tree information provided by the Atlas API in a Qt Model""" - - def __init__(self, data: List, parent=None): - super().__init__() - self.root_item = StructureTreeItem(data=("acronym", "name", "id")) - self.build_structure_tree(data, self.root_item) - - def build_structure_tree(self, structures: List, root: StructureTreeItem): - """Build the structure tree given a list of structures.""" - tree = get_structures_tree(structures) - structure_id_dict = {} - for structure in structures: - structure_id_dict[structure["id"]] = structure - - inserted_items: Dict[int, StructureTreeItem] = {} - for n_id in tree.expand_tree(): # sorts nodes by default, - # so parents will always be already in the QAbstractItemModel - # before their children - node = tree.get_node(n_id) - acronym = structure_id_dict[node.identifier]["acronym"] - name = structure_id_dict[node.identifier]["name"] - if ( - len(structure_id_dict[node.identifier]["structure_id_path"]) - == 1 - ): - parent_item = root - else: - parent_id = tree.parent(node.identifier).identifier - parent_item = inserted_items[parent_id] - - item = StructureTreeItem( - data=(acronym, name, node.identifier), parent=parent_item - ) - parent_item.appendChild(item) - inserted_items[node.identifier] = item - - def data(self, index: QModelIndex, role=Qt.DisplayRole): - """Provides read-only data for a given index if - intended for display, otherwise None.""" - if not index.isValid(): - return None - - if role != Qt.DisplayRole: - return None - - item = index.internalPointer() - - return item.data(index.column()) - - def rowCount(self, parent: StructureTreeItem): - """Returns the number of rows(i.e. children) of an item""" - if parent.column() > 0: - return 0 - - if not parent.isValid(): - parent_item = self.root_item - else: - parent_item = parent.internalPointer() - - return parent_item.childCount() - - def columnCount(self, parent: StructureTreeItem): - """The number of columns of an item.""" - if parent.isValid(): - return parent.internalPointer().columnCount() - else: - return self.root_item.columnCount() - - def parent(self, index: QModelIndex): - """The first-column index of parent of the item - at a given index. Returns an empty index if the root, - or an invalid index, is passed. - """ - if not index.isValid(): - return QModelIndex() - - child_item = index.internalPointer() - parent_item = child_item.parent() - - if parent_item == self.root_item: - return QModelIndex() - - return self.createIndex(parent_item.row(), 0, parent_item) - - def index(self, row, column, parent=QModelIndex()): - """The index of the item at (row, column) with a given parent. - By default, the given parent is assumed to be the root.""" - if not self.hasIndex(row, column, parent): - return QModelIndex() - - if not parent.isValid(): - parent_item = self.root_item - else: - parent_item = parent.internalPointer() - - child_item = parent_item.child(row) - if child_item: - return self.createIndex(row, column, child_item) - else: - return QModelIndex() - - class StructureView(QTreeView): add_structure_requested = Signal(str) diff --git a/tests/conftest.py b/tests/conftest.py index 19e7541..31de9c3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,9 @@ import os +import shutil from pathlib import Path import pytest -from bg_atlasapi import BrainGlobeAtlas, config +from bg_atlasapi import BrainGlobeAtlas, config, list_atlases from qtpy.QtCore import Qt @@ -88,3 +89,28 @@ def inner_double_click_on_view(view, index): ) return inner_double_click_on_view + + +@pytest.fixture +def mock_newer_atlas_version_available(): + current_version_path = Path.home() / ".brainglobe/example_mouse_100um_v1.2" + older_version_path = Path.home() / ".brainglobe/example_mouse_100um_v1.1" + assert current_version_path.exists() and not older_version_path.exists() + + current_version_path.rename(older_version_path) + assert older_version_path.exists() and not current_version_path.exists() + assert ( + list_atlases.get_atlases_lastversions()["example_mouse_100um"][ + "latest_version" + ] + == "1.2" + ) + assert list_atlases.get_local_atlas_version("example_mouse_100um") == "1.1" + + yield # run test with outdated version + + # cleanup: ensure version is up-to-date again + if older_version_path.exists(): + shutil.rmtree(path=older_version_path) + _ = BrainGlobeAtlas("example_mouse_100um") + assert current_version_path.exists() and not older_version_path.exists() diff --git a/tests/test_integration/test_atlas_version_manager_widget.py b/tests/test_integration/test_atlas_version_manager_widget.py new file mode 100644 index 0000000..4d703b0 --- /dev/null +++ b/tests/test_integration/test_atlas_version_manager_widget.py @@ -0,0 +1,63 @@ +import pytest + +from brainrender_napari.atlas_version_manager_widget import ( + AtlasVersionManagerWidget, +) + + +@pytest.fixture +def atlas_version_manager_widget(qtbot) -> AtlasVersionManagerWidget: + """A fixture to provide a version manager widget. + + Depends on qtbot so Qt event loop is started. + """ + return AtlasVersionManagerWidget() + + +def test_refresh_calls_view_constructor(atlas_version_manager_widget, mocker): + """Checks that refreshing the version manager widget + calls the view's constructor.""" + atlas_manager_view_mock = mocker.patch( + "brainrender_napari.atlas_version_manager_widget.AtlasManagerView" + ) + atlas_version_manager_widget._refresh() + atlas_manager_view_mock.assert_called_once_with( + parent=atlas_version_manager_widget + ) + + +def test_refresh_on_download(qtbot, mocker): + """Checks that when the view signals an atlas has been downloaded, + the version manager widget is refreshed.""" + refresh_mock = mocker.patch( + "brainrender_napari.atlas_version_manager_widget.AtlasVersionManagerWidget._refresh" + ) + # Don't use atlas_version_manager_widget fixture here, + # because otherwise mocking is ineffectual! + atlas_version_manager_widget = AtlasVersionManagerWidget() + with qtbot.waitSignal( + atlas_version_manager_widget.atlas_manager_view.download_atlas_confirmed + ): + atlas_version_manager_widget.atlas_manager_view.download_atlas_confirmed.emit( + "allen_mouse_100um" + ) + refresh_mock.assert_called_once_with("allen_mouse_100um") + + +def test_refresh_on_update(qtbot, mocker): + """Checks that when the view signals an atlas has been updated, + the version manager widget is updated.""" + refresh_mock = mocker.patch( + "brainrender_napari.atlas_version_manager_widget" + ".AtlasVersionManagerWidget._refresh" + ) + # Don't use atlas_version_manager_widget fixture here, + # because otherwise mocking is ineffectual! + atlas_version_manager_widget = AtlasVersionManagerWidget() + with qtbot.waitSignal( + atlas_version_manager_widget.atlas_manager_view.update_atlas_confirmed + ): + atlas_version_manager_widget.atlas_manager_view.update_atlas_confirmed.emit( + "allen_mouse_100um" + ) + refresh_mock.assert_called_once_with("allen_mouse_100um") diff --git a/tests/test_integration/test_brainrender_widget.py b/tests/test_integration/test_brainrender_widget.py index bc486e1..8bb0959 100644 --- a/tests/test_integration/test_brainrender_widget.py +++ b/tests/test_integration/test_brainrender_widget.py @@ -17,18 +17,6 @@ def brainrender_widget(make_napari_viewer) -> BrainrenderWidget: return BrainrenderWidget(viewer) -def test_download_confirmed_refreshes_view(brainrender_widget, mocker): - structure_view_refresh_mock = mocker.patch( - "brainrender_napari.brainrender_widget.StructureView.refresh" - ) - brainrender_widget.atlas_table_view.download_atlas_confirmed.emit( - "allen_mouse_10um" - ) - structure_view_refresh_mock.assert_called_once_with( - "allen_mouse_10um", False - ) - - @pytest.mark.parametrize( "expected_visibility, atlas", [ diff --git a/tests/test_unit/test_atlas_manager_view.py b/tests/test_unit/test_atlas_manager_view.py new file mode 100644 index 0000000..1b1e421 --- /dev/null +++ b/tests/test_unit/test_atlas_manager_view.py @@ -0,0 +1,103 @@ +import shutil +from pathlib import Path + +import pytest + +from brainrender_napari.widgets.atlas_manager_view import AtlasManagerView + + +@pytest.fixture +def atlas_manager_view(qtbot): + return AtlasManagerView() + + +def test_update_atlas_confirmed( + qtbot, + mock_newer_atlas_version_available, + atlas_manager_view, +): + """The order of fixtures matters here: + call mock before view constructor!""" + outdated_atlas_directory = ( + Path.home() / ".brainglobe/example_mouse_100um_v1.1" + ) + updated_atlas_directory = ( + Path.home() / ".brainglobe/example_mouse_100um_v1.2" + ) + assert ( + outdated_atlas_directory.exists() + and not updated_atlas_directory.exists() + ) + local_version_index = atlas_manager_view.model().index(0, 2) + assert atlas_manager_view.model().data(local_version_index) == "1.1" + + with qtbot.waitSignal( + atlas_manager_view.update_atlas_confirmed, + timeout=150000, # assumes atlas can be updated in 2.5 minutes! + ) as update_atlas_confirmed_signal: + # replace with double-click on view? + model_index = atlas_manager_view.model().index(0, 0) + atlas_manager_view.setCurrentIndex(model_index) + atlas_manager_view._on_update_atlas_confirmed() + + assert atlas_manager_view.model().data(local_version_index) == "1.2" + assert update_atlas_confirmed_signal.args == ["example_mouse_100um"] + assert ( + updated_atlas_directory.exists() + and not outdated_atlas_directory.exists() + ) + + +@pytest.mark.parametrize( + "row", + [ + 1, # "allen_mouse_10um" + 6, # "allen_human_500um" + ], +) +def test_double_click_on_not_yet_downloaded_atlas_row( + atlas_manager_view, mocker, double_click_on_view, row +): + """Check for a few yet-to-be-downloaded atlases that double-clicking + them on the atlas table view executes the download dialog. + """ + + model_index = atlas_manager_view.model().index(row, 1) + atlas_manager_view.setCurrentIndex(model_index) + + dialog_exec_mock = mocker.patch( + "brainrender_napari.widgets.atlas_manager_view.AtlasManagerDialog.exec" + ) + double_click_on_view(atlas_manager_view, model_index) + dialog_exec_mock.assert_called_once() + + +def test_download_confirmed_callback(atlas_manager_view, qtbot): + """Checks that confirming atlas download creates local copy of + example atlas files and emits expected signal. + + Test setup consists of remembering the expected files and folders + of a preexisting atlas and then removing them. This allows checking + that the function triggers the creation of the same local copy + of the atlas as the `bg_atlasapi` itself. + """ + atlas_directory = Path.home() / ".brainglobe/example_mouse_100um_v1.2" + expected_filenames = atlas_directory.iterdir() + shutil.rmtree( + path=atlas_directory + ) # now remove local copy so button has to trigger download + assert not Path.exists( + atlas_directory + ) # sanity check that local copy is gone + + with qtbot.waitSignal( + atlas_manager_view.download_atlas_confirmed, + timeout=150000, # assumes atlas can be installed in 2.5 minutes! + ) as download_atlas_confirmed_signal: + model_index = atlas_manager_view.model().index(0, 0) + atlas_manager_view.setCurrentIndex(model_index) + atlas_manager_view._on_download_atlas_confirmed() + + assert download_atlas_confirmed_signal.args == ["example_mouse_100um"] + for file in expected_filenames: + assert Path.exists(file) diff --git a/tests/test_unit/test_atlas_table_model.py b/tests/test_unit/test_atlas_table_model.py new file mode 100644 index 0000000..0c2d05b --- /dev/null +++ b/tests/test_unit/test_atlas_table_model.py @@ -0,0 +1,58 @@ +import pytest +from qtpy.QtCore import Qt + +from brainrender_napari.data_models.atlas_table_model import AtlasTableModel + + +@pytest.fixture +def atlas_table_model(): + return AtlasTableModel() + + +@pytest.mark.parametrize( + "column, expected_header", + [ + (0, "Raw name"), + (1, "Atlas"), + (2, "Local version"), + (3, "Latest version"), + ], +) +def test_model_header(atlas_table_model, column, expected_header): + """Check the table model has expected header data""" + assert ( + atlas_table_model.headerData( + column, Qt.Orientation.Horizontal, Qt.DisplayRole + ) + == expected_header + ) + + +def test_model_header_invalid_column(atlas_table_model): + """Check the table model throws as expected for invalid column""" + invalid_column = 4 + with pytest.raises(ValueError): + atlas_table_model.headerData( + invalid_column, Qt.Orientation.Horizontal, Qt.DisplayRole + ) + + +def test_get_tooltip_downloaded(): + """Check tooltip on an example in the downloaded test data""" + tooltip_text = AtlasTableModel._get_tooltip_text("example_mouse_100um") + assert "example_mouse" in tooltip_text + assert "add to viewer" in tooltip_text + + +def test_get_tooltip_not_locally_available(): + """Check tooltip on an example in not-downloaded test data""" + tooltip_text = AtlasTableModel._get_tooltip_text("allen_human_500um") + assert "allen_human_500um" in tooltip_text + assert "double-click to download" in tooltip_text + + +def test_get_tooltip_invalid_name(): + """Check tooltip on non-existent test data""" + with pytest.raises(ValueError) as e: + _ = AtlasTableModel._get_tooltip_text("wrong_atlas_name") + assert "invalid atlas name" in e diff --git a/tests/test_unit/test_atlas_table_view.py b/tests/test_unit/test_atlas_table_view.py deleted file mode 100644 index a5ff098..0000000 --- a/tests/test_unit/test_atlas_table_view.py +++ /dev/null @@ -1,211 +0,0 @@ -import shutil -from pathlib import Path - -import pytest -from qtpy.QtCore import QModelIndex, Qt - -from brainrender_napari.widgets.atlas_table_view import ( - AtlasTableModel, - AtlasTableView, -) - - -@pytest.fixture -def atlas_table_view(qtbot) -> AtlasTableView: - """Fixture to provide a valid atlas table view. - - Depends on qtbot fixture to provide the qt event loop. - """ - return AtlasTableView() - - -@pytest.mark.parametrize( - "row, expected_atlas_name", - [ - (4, "allen_mouse_100um"), # part of downloaded test data - (6, "allen_human_500um"), # not part of downloaded test data - ], -) -def test_atlas_table_view_valid_selection( - row, expected_atlas_name, atlas_table_view -): - """Checks selected_atlas_name for valid current indices""" - model_index = atlas_table_view.model().index(row, 0) - atlas_table_view.setCurrentIndex(model_index) - assert atlas_table_view.selected_atlas_name() == expected_atlas_name - - -def test_atlas_table_view_invalid_selection(atlas_table_view): - """Checks that selected_atlas_name throws an assertion error - if current index is invalid.""" - with pytest.raises(AssertionError): - atlas_table_view.setCurrentIndex(QModelIndex()) - atlas_table_view.selected_atlas_name() - - -def test_hover_atlas_table_view(atlas_table_view, mocker): - """Check tooltip is called when hovering over view""" - index = atlas_table_view.model().index(2, 1) - - get_tooltip_text_mock = mocker.patch( - "brainrender_napari.widgets" - ".atlas_table_view.AtlasTableModel._get_tooltip_text" - ) - - atlas_table_view.model().data(index, Qt.ToolTipRole) - - get_tooltip_text_mock.assert_called_once() - - -@pytest.mark.parametrize( - "column, expected_header", - [ - (0, "Atlas name"), - (1, "Latest version"), - ], -) -def test_model_header(atlas_table_view, column, expected_header): - """Check the table model has expected header data""" - assert ( - atlas_table_view.model().headerData( - column, Qt.Orientation.Horizontal, Qt.DisplayRole - ) - == expected_header - ) - - -def test_model_header_invalid_column(atlas_table_view): - """Check the table model throws as expected for invalid column""" - invalid_column = 2 - with pytest.raises(ValueError): - atlas_table_view.model().headerData( - invalid_column, Qt.Orientation.Horizontal, Qt.DisplayRole - ) - - -def test_get_tooltip_downloaded(): - """Check tooltip on an example in the downloaded test data""" - tooltip_text = AtlasTableModel._get_tooltip_text("example_mouse_100um") - assert "example_mouse" in tooltip_text - assert "add to viewer" in tooltip_text - - -def test_get_tooltip_not_locally_available(): - """Check tooltip on an example in not-downloaded test data""" - tooltip_text = AtlasTableModel._get_tooltip_text("allen_human_500um") - assert "allen_human_500um" in tooltip_text - assert "double-click to download" in tooltip_text - - -def test_get_tooltip_invalid_name(): - """Check tooltip on non-existent test data""" - with pytest.raises(ValueError) as e: - _ = AtlasTableModel._get_tooltip_text("wrong_atlas_name") - assert "invalid atlas name" in e - - -@pytest.mark.parametrize( - "row", - [ - 1, # "allen_mouse_10um" - 6, # "allen_human_500um" - ], -) -def test_double_click_on_not_yet_downloaded_atlas_row( - atlas_table_view, mocker, double_click_on_view, row -): - """Check for a few yet-to-be-downloaded atlases that double-clicking - them on the atlas table view executes the download dialog. - """ - - model_index = atlas_table_view.model().index(row, 0) - atlas_table_view.setCurrentIndex(model_index) - - dialog_exec_mock = mocker.patch( - "brainrender_napari.widgets.atlas_table_view.AtlasDownloadDialog.exec" - ) - double_click_on_view(atlas_table_view, model_index) - dialog_exec_mock.assert_called_once() - - -@pytest.mark.parametrize( - "row,expected_atlas_name", - [ - (0, "example_mouse_100um"), - (4, "allen_mouse_100um"), - (14, "osten_mouse_100um"), - ], -) -def test_double_click_on_locally_available_atlas_row( - atlas_table_view, double_click_on_view, qtbot, row, expected_atlas_name -): - """Check for a few locally available low-res atlases that double-clicking - them on the atlas table view emits a signal with their expected names. - """ - model_index = atlas_table_view.model().index(row, 0) - atlas_table_view.setCurrentIndex(model_index) - - with qtbot.waitSignal( - atlas_table_view.add_atlas_requested - ) as add_atlas_requested_signal: - double_click_on_view(atlas_table_view, model_index) - - assert add_atlas_requested_signal.args == [expected_atlas_name] - - -def test_additional_reference_menu(atlas_table_view, qtbot, mocker): - """Checks callback to additional reference menu calls QMenu exec - and emits expected signal""" - atlas_table_view.selectRow(5) # mpin_zfish_1um is in row 5 - from qtpy.QtCore import QPoint - from qtpy.QtWidgets import QAction - - x = atlas_table_view.rowViewportPosition(5) - y = atlas_table_view.columnViewportPosition(0) - position = QPoint(x, y) - qmenu_exec_mock = mocker.patch( - "brainrender_napari.widgets.atlas_table_view.QMenu.exec" - ) - qmenu_exec_mock.return_value = QAction("mock_additional_reference") - - with qtbot.waitSignal( - atlas_table_view.additional_reference_requested - ) as additional_reference_requested_signal: - atlas_table_view.customContextMenuRequested.emit(position) - - qmenu_exec_mock.assert_called_once() - assert additional_reference_requested_signal.args == [ - "mock_additional_reference" - ] - - -def test_download_confirmed_callback(atlas_table_view, qtbot): - """Checks that confirming atlas download creates local copy of - example atlas files and emits expected signal. - - Test setup consists of remembering the expected files and folders - of a preexisting atlas and then removing them. This allows checking - that the function triggers the creation of the same local copy - of the atlas as the `bg_atlasapi` itself. - """ - - atlas_directory = Path.home() / ".brainglobe/example_mouse_100um_v1.2" - expected_filenames = atlas_directory.iterdir() - shutil.rmtree( - path=atlas_directory - ) # now remove local copy so button has to trigger download - assert not Path.exists( - atlas_directory - ) # sanity check that local copy is gone - - with qtbot.waitSignal( - atlas_table_view.download_atlas_confirmed, - timeout=150000, # assumes atlas can be installed in 2.5 minutes! - ) as download_atlas_confirmed_signal: - model_index = atlas_table_view.model().index(0, 0) - atlas_table_view.setCurrentIndex(model_index) - atlas_table_view._on_download_atlas_confirmed() - - assert download_atlas_confirmed_signal.args == ["example_mouse_100um"] - for file in expected_filenames: - assert Path.exists(file) diff --git a/tests/test_unit/test_atlas_viewer_view.py b/tests/test_unit/test_atlas_viewer_view.py new file mode 100644 index 0000000..35186d7 --- /dev/null +++ b/tests/test_unit/test_atlas_viewer_view.py @@ -0,0 +1,121 @@ +import traceback + +import pytest +from qtpy.QtCore import QModelIndex, Qt + +from brainrender_napari.widgets.atlas_viewer_view import ( + AtlasViewerView, +) + + +@pytest.fixture +def atlas_viewer_view(qtbot) -> AtlasViewerView: + """Fixture to provide a valid atlas table view. + + Depends on qtbot fixture to provide the qt event loop. + """ + return AtlasViewerView() + + +@pytest.mark.parametrize( + "row, expected_atlas_name", + [ + (0, "example_mouse_100um"), + (4, "allen_mouse_100um"), + ], +) +def test_atlas_view_valid_selection( + row, expected_atlas_name, atlas_viewer_view +): + """Checks selected_atlas_name for valid current indices""" + model_index = atlas_viewer_view.model().index(row, 0) + atlas_viewer_view.setCurrentIndex(model_index) + assert atlas_viewer_view.selected_atlas_name() == expected_atlas_name + + +def test_atlas_view_invalid_selection(atlas_viewer_view): + """Checks that selected_atlas_name throws an assertion error + if current index is invalid.""" + with pytest.raises(AssertionError): + atlas_viewer_view.setCurrentIndex(QModelIndex()) + atlas_viewer_view.selected_atlas_name() + + +def test_atlas_view_not_downloaded_selection(qtbot, atlas_viewer_view): + """Checks that selected_atlas_name raises an assertion error + if current index is valid, but not a downloaded atlas. + """ + with qtbot.capture_exceptions() as exceptions: + # should raise because human atlas (row 6) is not available + # exception raised within qt loop in this case. + model_index = atlas_viewer_view.model().index(6, 0) + atlas_viewer_view.setCurrentIndex(model_index) + assert len(exceptions) == 1 + _, exception, collected_traceback = exceptions[0] # ignore type + assert isinstance(exception, AssertionError) + assert "selected_atlas_name" in traceback.format_tb(collected_traceback)[0] + + +def test_hover_atlas_view(atlas_viewer_view, mocker): + """Check tooltip is called when hovering over view""" + index = atlas_viewer_view.model().index(2, 1) + + get_tooltip_text_mock = mocker.patch( + "brainrender_napari.data_models" + ".atlas_table_model.AtlasTableModel._get_tooltip_text" + ) + + atlas_viewer_view.model().data(index, Qt.ToolTipRole) + + get_tooltip_text_mock.assert_called_once() + + +@pytest.mark.parametrize( + "row,expected_atlas_name", + [ + (0, "example_mouse_100um"), + (4, "allen_mouse_100um"), + (14, "osten_mouse_100um"), + ], +) +def test_double_click_on_locally_available_atlas_row( + atlas_viewer_view, double_click_on_view, qtbot, row, expected_atlas_name +): + """Check for a few locally available low-res atlases that double-clicking + them on the atlas table view emits a signal with their expected names. + """ + model_index = atlas_viewer_view.model().index(row, 1) + atlas_viewer_view.setCurrentIndex(model_index) + + with qtbot.waitSignal( + atlas_viewer_view.add_atlas_requested + ) as add_atlas_requested_signal: + double_click_on_view(atlas_viewer_view, model_index) + + assert add_atlas_requested_signal.args == [expected_atlas_name] + + +def test_additional_reference_menu(atlas_viewer_view, qtbot, mocker): + """Checks callback to additional reference menu calls QMenu exec + and emits expected signal""" + atlas_viewer_view.selectRow(5) # mpin_zfish_1um is in row 5 + from qtpy.QtCore import QPoint + from qtpy.QtWidgets import QAction + + x = atlas_viewer_view.rowViewportPosition(5) + y = atlas_viewer_view.columnViewportPosition(1) + position = QPoint(x, y) + qmenu_exec_mock = mocker.patch( + "brainrender_napari.widgets.atlas_viewer_view.QMenu.exec" + ) + qmenu_exec_mock.return_value = QAction("mock_additional_reference") + + with qtbot.waitSignal( + atlas_viewer_view.additional_reference_requested + ) as additional_reference_requested_signal: + atlas_viewer_view.customContextMenuRequested.emit(position) + + qmenu_exec_mock.assert_called_once() + assert additional_reference_requested_signal.args == [ + "mock_additional_reference" + ] diff --git a/tests/test_unit/test_download_dialog.py b/tests/test_unit/test_download_dialog.py deleted file mode 100644 index dd02ab4..0000000 --- a/tests/test_unit/test_download_dialog.py +++ /dev/null @@ -1,22 +0,0 @@ -import pytest - -from brainrender_napari.widgets.atlas_download_dialog import ( - AtlasDownloadDialog, -) - - -def test_download_dialog(qtbot): - """Check download dialog constructor and buttons connections""" - dialog = AtlasDownloadDialog("example_mouse_100um") - with qtbot.waitSignal(dialog.accepted): - dialog.ok_button.click() - - with qtbot.waitSignal(dialog.rejected): - dialog.cancel_button.click() - - -def test_download_dialog_raises(): - """Check download dialog constructor errors on invalid input""" - with pytest.raises(ValueError) as e: - _ = AtlasDownloadDialog("wrong_atlas_name") - assert "invalid atlas name" in e diff --git a/tests/test_unit/test_download_update_dialog.py b/tests/test_unit/test_download_update_dialog.py new file mode 100644 index 0000000..bcb5297 --- /dev/null +++ b/tests/test_unit/test_download_update_dialog.py @@ -0,0 +1,24 @@ +import pytest + +from brainrender_napari.widgets.atlas_manager_dialog import ( + AtlasManagerDialog, +) + + +@pytest.mark.parametrize("action", ["Download", "Update"]) +def test_download_update_dialog(action, qtbot): + """Check download dialog constructor and buttons connections""" + dialog = AtlasManagerDialog("example_mouse_100um", action) + with qtbot.waitSignal(dialog.accepted): + dialog.ok_button.click() + + with qtbot.waitSignal(dialog.rejected): + dialog.cancel_button.click() + + +@pytest.mark.parametrize("action", ["Download", "Update"]) +def test_download_update_dialog_raises(action): + """Check download dialog constructor errors on invalid input""" + with pytest.raises(ValueError) as e: + _ = AtlasManagerDialog("wrong_atlas_name", action) + assert [action, "invalid atlas name"] in e