diff --git a/brainrender_napari/brainrender_widget.py b/brainrender_napari/brainrender_widget.py index b2e8348..8e2deee 100644 --- a/brainrender_napari/brainrender_widget.py +++ b/brainrender_napari/brainrender_widget.py @@ -1,11 +1,11 @@ """ A napari widget to view atlases. -Atlases that are exposed by the Brainglobe atlas API are +Locally available atlases are shown in a table view using the Qt model/view framework [Qt Model/View framework](https://doc.qt.io/qt-6/model-view-programming.html) -Users can download and add the atlas images/structures as layers to the viewer. +Users can add the atlas images/structures as layers to the viewer. """ from bg_atlasapi import BrainGlobeAtlas from bg_atlasapi.list_atlases import get_downloaded_atlases @@ -21,16 +21,14 @@ 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 class BrainrenderWidget(QWidget): """The purpose of this class is * to hold atlas visualisation widgets for napari - * coordinate between these widgets and napari by - * creating appropriate signal-slot connections - * creating napari representations as requested + * coordinate between these widgets and napari """ def __init__(self, napari_viewer: Viewer): @@ -43,7 +41,7 @@ def __init__(self, napari_viewer: Viewer): self.layout().addWidget(header_widget()) # create widgets - self.atlas_table_view = AtlasTableView(parent=self) + self.atlas_viewer_view = AtlasViewerView(parent=self) self.show_structure_names = QCheckBox() self.show_structure_names.setChecked(False) @@ -56,14 +54,14 @@ def __init__(self, napari_viewer: Viewer): self.structure_view = StructureView(parent=self) # add widgets to the layout as group boxes - self.atlas_table_group = QGroupBox("Atlas table view") - self.atlas_table_group.setToolTip( - "Double-click on row to download/add annotations and reference\n" + self.atlas_viewer_group = QGroupBox("Atlas Viewer") + self.atlas_viewer_group.setToolTip( + "Double-click on row to add annotations and reference\n" "Right-click to add additional reference images (if any exist)" ) - self.atlas_table_group.setLayout(QVBoxLayout()) - self.atlas_table_group.layout().addWidget(self.atlas_table_view) - self.layout().addWidget(self.atlas_table_group) + self.atlas_viewer_group.setLayout(QVBoxLayout()) + self.atlas_viewer_group.layout().addWidget(self.atlas_viewer_view) + self.layout().addWidget(self.atlas_viewer_group) self.structure_tree_group = QGroupBox("3D Atlas region meshes") self.structure_tree_group.setToolTip( @@ -79,16 +77,13 @@ 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.atlas_viewer_view.add_atlas_requested.connect( self._on_add_atlas_requested ) - self.atlas_table_view.additional_reference_requested.connect( + self.atlas_viewer_view.additional_reference_requested.connect( self._on_additional_reference_requested ) - self.atlas_table_view.selected_atlas_changed.connect( + self.atlas_viewer_view.selected_atlas_changed.connect( self._on_atlas_selection_changed ) @@ -102,15 +97,10 @@ 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( - self.atlas_table_view.selected_atlas_name() + self.atlas_viewer_view.selected_atlas_name() ) selected_atlas_representation = NapariAtlasRepresentation( selected_atlas, self._viewer @@ -121,7 +111,7 @@ def _on_additional_reference_requested( self, additional_reference_name: str ): """Add additional reference as napari atlas representation""" - atlas = BrainGlobeAtlas(self.atlas_table_view.selected_atlas_name()) + atlas = BrainGlobeAtlas(self.atlas_viewer_view.selected_atlas_name()) atlas_representation = NapariAtlasRepresentation(atlas, self._viewer) atlas_representation.add_additional_reference( additional_reference_name @@ -144,6 +134,6 @@ def _on_add_atlas_requested(self, atlas_name: str): selected_atlas_representation.add_to_viewer() def _on_show_structure_names_clicked(self): - atlas_name = self.atlas_table_view.selected_atlas_name() + atlas_name = self.atlas_viewer_view.selected_atlas_name() show_structure_names = self.show_structure_names.isChecked() self.structure_view.refresh(atlas_name, show_structure_names) 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..e05b587 --- /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.column_headers = [ + "Raw name", + "Atlas", + "Local version", + "Latest version", + ] + 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 and section < len(self.column_headers): + return self.column_headers[section] + 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/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..596e504 --- /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 column_header in ["Raw name", "Local version", "Latest version"]: + index_to_hide = self.model().column_headers.index(column_header) + self.hideColumn(index_to_hide) + + if len(get_downloaded_atlases()) == 0: + self.no_atlas_available.emit() + + # hide atlases not available locally + 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/tests/test_integration/test_brainrender_widget.py b/tests/test_integration/test_brainrender_widget.py index bc486e1..e0a8d2c 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", [ @@ -58,16 +46,16 @@ def test_double_click_on_locally_available_atlas_row( brainrender_widget, mocker, qtbot, expected_atlas_name ): """Check for a few local low-res atlases that double-clicking them - on the atlas table view calls the expected atlas representation function. + on the atlas viewer view calls the expected atlas representation function. """ add_atlas_to_viewer_mock = mocker.patch( "brainrender_napari.brainrender_widget" ".NapariAtlasRepresentation.add_to_viewer" ) with qtbot.waitSignal( - brainrender_widget.atlas_table_view.add_atlas_requested + brainrender_widget.atlas_viewer_view.add_atlas_requested ): - brainrender_widget.atlas_table_view.add_atlas_requested.emit( + brainrender_widget.atlas_viewer_view.add_atlas_requested.emit( expected_atlas_name ) add_atlas_to_viewer_mock.assert_called_once() @@ -82,7 +70,7 @@ def test_structure_row_double_clicked(brainrender_widget, mocker): "brainrender_napari.brainrender_widget" ".NapariAtlasRepresentation.add_structure_to_viewer" ) - brainrender_widget.atlas_table_view.selectRow( + brainrender_widget.atlas_viewer_view.selectRow( 4 ) # allen_mouse_100um is in row 4 @@ -91,22 +79,22 @@ def test_structure_row_double_clicked(brainrender_widget, mocker): def test_add_additional_reference_selected(brainrender_widget, mocker): - """Checks that when the atlas table view requests an additional + """Checks that when the atlas viewer view requests an additional reference, the NapariAtlasRepresentation function is called in the expected way.""" add_additional_reference_mock = mocker.patch( "brainrender_napari.brainrender_widget" ".NapariAtlasRepresentation.add_additional_reference" ) - brainrender_widget.atlas_table_view.selectRow( + brainrender_widget.atlas_viewer_view.selectRow( 5 ) # mpin_zfish_1um is in row 5 assert ( - brainrender_widget.atlas_table_view.selected_atlas_name() + brainrender_widget.atlas_viewer_view.selected_atlas_name() == "mpin_zfish_1um" ) additional_reference_name = "GAD1b" - brainrender_widget.atlas_table_view.additional_reference_requested.emit( + brainrender_widget.atlas_viewer_view.additional_reference_requested.emit( additional_reference_name ) add_additional_reference_mock.assert_called_once_with( @@ -118,7 +106,7 @@ def test_show_structures_checkbox(brainrender_widget, mocker): structure_view_refresh_mock = mocker.patch( "brainrender_napari.brainrender_widget.StructureView.refresh" ) - brainrender_widget.atlas_table_view.selectRow( + brainrender_widget.atlas_viewer_view.selectRow( 0 ) # example_mouse_100um is in row 0 structure_view_refresh_mock.assert_called_with( @@ -147,10 +135,9 @@ def test_structure_view_tooltip(brainrender_widget): ) -def test_atlas_table_view_tooltip(brainrender_widget): +def test_atlas_viewer_view_tooltip(brainrender_widget): for expected_keyword in [ "double-click", - "download", "add", "annotations", "reference", @@ -159,5 +146,5 @@ def test_atlas_table_view_tooltip(brainrender_widget): ]: assert ( expected_keyword - in brainrender_widget.atlas_table_group.toolTip().lower() + in brainrender_widget.atlas_viewer_group.toolTip().lower() ) 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..b3c458c --- /dev/null +++ b/tests/test_unit/test_atlas_table_model.py @@ -0,0 +1,60 @@ +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 + both via the function and the member variable.""" + assert ( + atlas_table_model.headerData( + column, Qt.Orientation.Horizontal, Qt.DisplayRole + ) + == expected_header + ) + assert atlas_table_model.column_headers[column] == expected_header + + +def test_model_header_invalid_column(atlas_table_model): + """Check the table model throws a value error 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" + ]