diff --git a/client/securedrop_client/gui/widgets.py b/client/securedrop_client/gui/widgets.py index 486c685c5..ea0a1412b 100644 --- a/client/securedrop_client/gui/widgets.py +++ b/client/securedrop_client/gui/widgets.py @@ -55,6 +55,7 @@ QScrollArea, QSizePolicy, QSpacerItem, + QStackedLayout, QStatusBar, QToolBar, QToolButton, @@ -437,7 +438,6 @@ class InnerTopPane(QWidget): def __init__(self) -> None: super().__init__() self.setObjectName("InnerTopPane") - logger.debug("Construct Inner Top pane") # Use a vertical layout so that the keyword search bar can be added later layout = QVBoxLayout(self) @@ -743,6 +743,14 @@ class MainView(QWidget): main context view, and top actions pane). """ + # Index items for StackedLayout. CONVERSATION_INDEX should remain the + # biggest int value, for future ease of caching and cleaning up additional + # optional pages (eg rendered conversations) in higher index positions + NO_SOURCES_INDEX = 0 + NOTHING_SELECTED_INDEX = 1 + MULTI_SELECTED_INDEX = 2 + CONVERSATION_INDEX = 3 + def __init__( self, parent: Optional[QWidget], @@ -785,12 +793,22 @@ def __init__( # Create widgets self.view_holder = QWidget() self.view_holder.setObjectName("MainView_view_holder") - self.view_layout = QVBoxLayout() + + # Layout where only one view shows at a time. Suitable for the case + # where we show either a conversation or a contextually-appropriate + # message ("Select a source...", "Nothing to see yet", etc) + self.view_layout = QStackedLayout() self.view_holder.setLayout(self.view_layout) self.view_layout.setContentsMargins(0, 0, 0, 0) self.view_layout.setSpacing(0) - self.empty_conversation_view = EmptyConversationView() - self.view_layout.addWidget(self.empty_conversation_view) + + self.view_layout.insertWidget(self.NO_SOURCES_INDEX, EmptyConversationView()) + self.view_layout.insertWidget(self.NOTHING_SELECTED_INDEX, NothingSelectedView()) + self.view_layout.insertWidget(self.MULTI_SELECTED_INDEX, MultiSelectView()) + + # Placeholder widget at the CONVERSATION_INDEX, dynamically replaced by conversation view + # as soon as a source conversation is selected + self.view_layout.insertWidget(self.CONVERSATION_INDEX, NothingSelectedView()) # Add widgets to layout inner_container.addWidget(self.source_list, stretch=1) @@ -827,10 +845,6 @@ def show_sources(self, sources: list[Source]) -> None: """ Update the sources list in the GUI with the supplied list of sources. """ - # Show the correct conversation pane gui element depending on - # the number of sources a) available and b) selected. - self._update_conversation_context(sources, is_redraw_event=True) - # If the source list in the GUI is empty, then we will run the optimized initial update. # Otherwise, do a regular source list update. if not self.source_list.source_items: @@ -841,13 +855,18 @@ def show_sources(self, sources: list[Source]) -> None: # Then call the function to remove the wrapper and its children. self.delete_conversation(source_uuid) - def _update_conversation_context( - self, sources: list[Source] | None = None, is_redraw_event: bool = False - ) -> int: + # Show the correct conversation pane gui element depending on + # the number of sources a) available and b) selected. + # An improved approach will be to create an `on_source_context_update` + # pyQtSlot that subscribes/listens for storage updates and calls + # `show_sources` and `show_conversation_context`. + self._on_update_conversation_context() + + def _on_update_conversation_context(self) -> None: """ Show the correct view type based on the number of available and selected sources. - If there are no sources, show the EmptyConversationView. + If there are no sources, show the empty conversation view. If there are sources, but none are selected, show the "Select a source" view. If there are sources and exactly one has been selected, show the conversation with that source. @@ -861,20 +880,15 @@ def _update_conversation_context( Return number of selected sources. """ selected = len(self.source_list.selectedItems()) - if is_redraw_event and not sources: - self.empty_conversation_view.show_no_sources_message() - self.empty_conversation_view.show() + if selected == 0 and self.source_list.count() == 0: + self.view_layout.setCurrentIndex(self.NO_SOURCES_INDEX) elif selected == 0: - self.empty_conversation_view.show_no_source_selected_message() - self.empty_conversation_view.show() + self.view_layout.setCurrentIndex(self.NOTHING_SELECTED_INDEX) elif selected > 1: - self.empty_conversation_view.show_multi_select_message() - self.empty_conversation_view.show() + self.view_layout.setCurrentIndex(self.MULTI_SELECTED_INDEX) else: # Exactly one source selected - self.empty_conversation_view.hide() - - return selected + self.view_layout.setCurrentIndex(self.CONVERSATION_INDEX) @pyqtSlot() def on_source_changed(self) -> None: @@ -883,12 +897,9 @@ def on_source_changed(self) -> None: show multi select view. """ - selected = self._update_conversation_context() - if selected != 1: - # The expanded conversation view is shown when exactly one source is selected - self.hide_conversation_widget() - return - else: + selected = len(self.source_list.selectedItems()) + if selected == 1: + # One source selected; prepare the conversation widget try: source = self.source_list.get_selected_source() if not source: @@ -913,12 +924,16 @@ def on_source_changed(self) -> None: ) self.source_conversations[source.uuid] = conversation_wrapper + # Put this widget into the QStackedLayout at the correct position self.set_conversation(conversation_wrapper) logger.debug(f"Set conversation to the selected source with uuid: {source.uuid}") except sqlalchemy.exc.InvalidRequestError as e: logger.debug(e) + # Now show the right widget depending on the selection + self._on_update_conversation_context() + def refresh_source_conversations(self) -> None: """ Refresh the selected source conversation. @@ -958,31 +973,26 @@ def delete_conversation(self, source_uuid: str) -> None: except KeyError: logger.debug(f"No SourceConversationWrapper for {source_uuid} to delete") - def set_conversation(self, widget: QWidget) -> None: + def set_conversation(self, conversation: SourceConversationWrapper) -> None: """ - Update the view holder to contain the referenced widget. + Replace rendered conversation at CONVERSATION_INDEX. Does not change + QStackedLayout current index. """ - old_widget = self.view_layout.takeAt(0) + self.view_layout.insertWidget(self.CONVERSATION_INDEX, conversation) - if old_widget and old_widget.widget(): - old_widget.widget().hide() + # At the moment, we don't keep these widgets as pages in the stacked layout, + # and we store an in-memory dict of {uuids: widget}s to avoid recreating a widget every + # time a conversation is revisited. A fixed-size cache could be implemented here instead. + layoutitem = self.view_layout.itemAt(self.CONVERSATION_INDEX + 1) + if layoutitem: + self.view_layout.removeWidget(layoutitem.widget()) - self.empty_conversation_view.hide() - self.view_layout.addWidget(widget) - widget.show() - - def hide_conversation_widget(self) -> None: - """ - Hide the conversation widget and show the EmptyConversationView - (used if no sources or multiple sources are selected). - """ - layout = self.view_layout.takeAt(0) - if layout and layout.widget() and isinstance(layout.widget(), SourceConversationWrapper): - layout.widget().hide() - self.empty_conversation_view.show() +class ConversationPaneView(QWidget): + """ + Base widget element for the ConversationPane. + """ -class EmptyConversationView(QWidget): MARGIN = 30 NEWLINE_HEIGHT_PX = 35 @@ -990,17 +1000,20 @@ def __init__(self) -> None: super().__init__() self.setObjectName("EmptyConversationView") + self._layout = QVBoxLayout() + self.setContentsMargins(self.MARGIN, self.MARGIN, self.MARGIN, self.MARGIN) + self._layout.setAlignment(Qt.AlignCenter) + self.setLayout(self._layout) - # Set layout - layout = QHBoxLayout() - layout.setContentsMargins(self.MARGIN, self.MARGIN, self.MARGIN, self.MARGIN) - self.setLayout(layout) - # Create widgets - self.no_sources = QWidget() - self.no_sources.setObjectName("EmptyConversationView_no_sources") - no_sources_layout = QVBoxLayout() - self.no_sources.setLayout(no_sources_layout) +class EmptyConversationView(ConversationPaneView): + """ + Displayed in conversation pane when sourcelist is empty. + """ + + def __init__(self) -> None: + super().__init__() + no_sources_instructions = QLabel(_("Nothing to see just yet!")) no_sources_instructions.setObjectName("EmptyConversationView_instructions") no_sources_instructions.setWordWrap(True) @@ -1012,16 +1025,21 @@ def __init__(self) -> None: _("This is where you will read messages, reply to sources, and work with files.") ) no_sources_instruction_details2.setWordWrap(True) - no_sources_layout.addWidget(no_sources_instructions) - no_sources_layout.addSpacing(self.NEWLINE_HEIGHT_PX) - no_sources_layout.addWidget(no_sources_instruction_details1) - no_sources_layout.addSpacing(self.NEWLINE_HEIGHT_PX) - no_sources_layout.addWidget(no_sources_instruction_details2) - - self.no_source_selected = QWidget() - self.no_source_selected.setObjectName("EmptyConversationView_no_source_selected") - no_source_selected_layout = QVBoxLayout() - self.no_source_selected.setLayout(no_source_selected_layout) + self._layout.addWidget(no_sources_instructions) + self._layout.addSpacing(self.NEWLINE_HEIGHT_PX) + self._layout.addWidget(no_sources_instruction_details1) + self._layout.addSpacing(self.NEWLINE_HEIGHT_PX) + self._layout.addWidget(no_sources_instruction_details2) + + +class NothingSelectedView(ConversationPaneView): + """ + Displayed in conversation pane when sources are present but none are selected. + """ + + def __init__(self) -> None: + super().__init__() + no_source_selected_instructions = QLabel(_("Select a source from the list, to:")) no_source_selected_instructions.setObjectName("EmptyConversationView_instructions") no_source_selected_instructions.setWordWrap(True) @@ -1057,20 +1075,23 @@ def __init__(self) -> None: ) no_source_selected_end_instructions.setObjectName("EmptyConversationView_instructions") no_source_selected_end_instructions.setWordWrap(True) - no_source_selected_layout.addWidget(no_source_selected_instructions) - no_source_selected_layout.addSpacing(self.NEWLINE_HEIGHT_PX) - no_source_selected_layout.addWidget(bullet1) - no_source_selected_layout.addWidget(bullet2) - no_source_selected_layout.addWidget(bullet3) - no_source_selected_layout.addSpacing(self.NEWLINE_HEIGHT_PX * 4) - no_source_selected_layout.addWidget(no_source_selected_end_instructions) - no_source_selected_layout.addSpacing(self.NEWLINE_HEIGHT_PX * 4) - - # Multi-source selection widget, same css properties as Empty view - self.multi_source_selected = QWidget() - self.multi_source_selected.setObjectName("EmptyConversationView_no_sources") - multi_sources_layout = QVBoxLayout() - self.multi_source_selected.setLayout(multi_sources_layout) + self._layout.addWidget(no_source_selected_instructions) + self._layout.addSpacing(self.NEWLINE_HEIGHT_PX) + self._layout.addWidget(bullet1) + self._layout.addWidget(bullet2) + self._layout.addWidget(bullet3) + self._layout.addSpacing(self.NEWLINE_HEIGHT_PX * 4) + self._layout.addWidget(no_source_selected_end_instructions) + self._layout.addSpacing(self.NEWLINE_HEIGHT_PX * 4) + + +class MultiSelectView(ConversationPaneView): + """ + Displayed in conversation pane when multiple sources are selected. + """ + + def __init__(self) -> None: + super().__init__() multi_sources_instructions = QLabel(_("Multiple Sources Selected")) multi_sources_instructions.setObjectName("EmptyConversationView_instructions") multi_sources_instructions.setWordWrap(True) @@ -1084,31 +1105,11 @@ def __init__(self) -> None: _("Use the top toolbar to delete multiple sources at once.") ) multi_sources_instruction_details2.setWordWrap(True) - multi_sources_layout.addWidget(multi_sources_instructions) - multi_sources_layout.addSpacing(self.NEWLINE_HEIGHT_PX) - multi_sources_layout.addWidget(multi_sources_instruction_details1) - multi_sources_layout.addSpacing(self.NEWLINE_HEIGHT_PX) - multi_sources_layout.addWidget(multi_sources_instruction_details2) - - # Add widgets - layout.addWidget(self.no_sources, alignment=Qt.AlignCenter) - layout.addWidget(self.no_source_selected, alignment=Qt.AlignCenter) - layout.addWidget(self.multi_source_selected, alignment=Qt.AlignCenter) - - def show_no_sources_message(self) -> None: - self.no_sources.show() - self.no_source_selected.hide() - self.multi_source_selected.hide() - - def show_no_source_selected_message(self) -> None: - self.no_sources.hide() - self.multi_source_selected.hide() - self.no_source_selected.show() - - def show_multi_select_message(self) -> None: - self.multi_source_selected.show() - self.no_sources.hide() - self.no_source_selected.hide() + self._layout.addWidget(multi_sources_instructions) + self._layout.addSpacing(self.NEWLINE_HEIGHT_PX) + self._layout.addWidget(multi_sources_instruction_details1) + self._layout.addSpacing(self.NEWLINE_HEIGHT_PX) + self._layout.addWidget(multi_sources_instruction_details2) class SourceListWidgetItem(QListWidgetItem): diff --git a/client/tests/gui/test_actions.py b/client/tests/gui/test_actions.py index 3a728c86e..25bb7b955 100644 --- a/client/tests/gui/test_actions.py +++ b/client/tests/gui/test_actions.py @@ -103,7 +103,9 @@ def test_deletes_source_when_dialog_accepted(self): self.action.trigger() self._controller.delete_sources.assert_called_once() - assert self._source in self._controller.delete_sources.call_args[0][0], self._controller.delete_sources.call_args[0][0] + assert ( + self._source in self._controller.delete_sources.call_args[0][0] + ), self._controller.delete_sources.call_args[0][0] def test_does_not_delete_source_when_dialog_rejected(self): # Reject the confirmation dialog from a separate thread. diff --git a/client/tests/gui/test_widgets.py b/client/tests/gui/test_widgets.py index 12317fa86..1cf86ac16 100644 --- a/client/tests/gui/test_widgets.py +++ b/client/tests/gui/test_widgets.py @@ -3,7 +3,7 @@ """ import math -from datetime import datetime +from datetime import datetime, timedelta from gettext import gettext as _ from unittest.mock import Mock, PropertyMock @@ -31,6 +31,8 @@ LoginButton, MainView, MessageWidget, + MultiSelectView, + NothingSelectedView, ReplyBoxWidget, ReplyTextEdit, ReplyTextEditPlaceholder, @@ -533,21 +535,19 @@ def test_MainView_show_sources_with_none_selected(mocker): # Set up SourceList so that SourceList.get_selected_source() returns a source mv.source_list = SourceList() + mv.source_list.controller = mocker.MagicMock() source_widget = SourceWidget( mocker.MagicMock(), factory.Source(uuid="stub_uuid"), mocker.MagicMock(), mocker.MagicMock() ) source_item = SourceListWidgetItem(mv.source_list) mv.source_list.setItemWidget(source_item, source_widget) mv.source_list.source_items["stub_uuid"] = source_item - mocker.patch.object(mv.source_list, "update_sources") + # mocker.patch.object(mv.source_list, "update_sources") - mv.empty_conversation_view = mocker.MagicMock() - - mv.show_sources([1, 2, 3]) + mv.show_sources([factory.Source(), factory.Source(), factory.Source()]) - mv.source_list.update_sources.assert_called_once_with([1, 2, 3]) - mv.empty_conversation_view.show_no_source_selected_message.assert_called_once_with() - mv.empty_conversation_view.show.assert_called_once_with() + # mv.source_list.update_sources.assert_called_once_with([1, 2, 3]) + assert mv.view_layout.currentIndex() == mv.NOTHING_SELECTED_INDEX def test_MainView_show_sources_from_cold_start(mocker): @@ -570,13 +570,16 @@ def test_MainView_show_sources_with_no_sources_at_all(mocker): """ mv = MainView(None) mv.source_list = mocker.MagicMock() - mv.empty_conversation_view = mocker.MagicMock() + mv.source_list.count = mocker.MagicMock(return_value=0) + mv.source_list.selectedItems = mocker.MagicMock(return_value=[]) + + mv._on_update_conversation_context = mocker.MagicMock(wraps=mv._on_update_conversation_context) mv.show_sources([]) mv.source_list.update_sources.assert_called_once_with([]) - mv.empty_conversation_view.show_no_sources_message.assert_called_once_with() - mv.empty_conversation_view.show.assert_called_once_with() + mv._on_update_conversation_context.assert_called() + assert mv.view_layout.currentIndex() == mv.NO_SOURCES_INDEX def test_MainView_show_sources_when_sources_are_deleted(mocker): @@ -584,18 +587,20 @@ def test_MainView_show_sources_when_sources_are_deleted(mocker): Ensure that show_sources also deletes the SourceConversationWrapper for a deleted source. """ mv = MainView(None) + sources = [factory.Source(), factory.Source(), factory.Source(), factory.Source()] mv.source_list = mocker.MagicMock() - mv.empty_conversation_view = mocker.MagicMock() + mv.source_list.count = mocker.MagicMock(return_value=len(sources)) + mv.source_list.getSelectedItems = mocker.MagicMock(return_value=[]) mv.source_list.update_sources = mocker.MagicMock(return_value=[]) mv.delete_conversation = mocker.MagicMock() - mv.show_sources([1, 2, 3, 4]) + mv.show_sources(sources) - mv.source_list.update_sources = mocker.MagicMock(return_value=[4]) + mv.source_list.update_sources = mocker.MagicMock(return_value=[sources[-1]]) - mv.show_sources([1, 2, 3]) + mv.show_sources(sources[:-1]) - mv.delete_conversation.assert_called_once_with(4) + mv.delete_conversation.assert_called_once_with(sources[-1]) def test_MainView_delete_conversation_when_conv_wrapper_exists(mocker): @@ -638,6 +643,8 @@ def test_MainView_on_source_changed(mocker): source = factory.Source() mv.source_list = mocker.MagicMock() mv.source_list.get_selected_source = mocker.MagicMock(return_value=source) + mv.source_list.selectedItems = mocker.MagicMock(return_value=[source]) + mv.source_list.count = mocker.MagicMock(return_value=3) mv.controller = mocker.MagicMock(is_authenticated=True) mocker.patch("securedrop_client.gui.widgets.source_exists", return_value=True) # scw = SourceConversationWrapper(source, mv.controller) @@ -646,7 +653,8 @@ def test_MainView_on_source_changed(mocker): mv.on_source_changed() mv.source_list.get_selected_source.assert_called_once_with() - mv.set_conversation.assert_called_once_with(SourceConversationWrapper(source, mv.controller)) + mv.set_conversation.assert_called_once() + assert mv.set_conversation.call_args[0][0].source == source def test_MainView_on_source_changed_shows_correct_context(mocker): @@ -654,10 +662,10 @@ def test_MainView_on_source_changed_shows_correct_context(mocker): Ensure correct context presented based on number of sources selected. """ # Build sourcelist - sources = [] - for i in range(0, 10): + sources = [] + for i in range(10): sources.append(factory.Source()) - + mock_controller = mocker.MagicMock(spec=logic.Controller) mock_session = mocker.MagicMock(is_authenticated=True) mock_controller.session = mock_session @@ -668,21 +676,22 @@ def test_MainView_on_source_changed_shows_correct_context(mocker): mv.show() mv.show_sources(sources) - assert mv.empty_conversation_view.isVisible() - assert mv.empty_conversation_view.no_source_selected.isVisible() - assert mv.empty_conversation_view.no_sources.isHidden() + assert mv.view_layout.currentIndex() == mv.NOTHING_SELECTED_INDEX # Select a source, ensure the correct view context is shown mv.source_list.item(1).setSelected(True) - assert mv.empty_conversation_view.isHidden() - assert mock_controller.session.refresh.call_args[0][0] == mv.source_list.itemWidget(mv.source_list.item(1)).source + assert mv.view_layout.currentItem() == mv.CONVERSATION_INDEX + assert ( + mock_controller.session.refresh.call_args[0][0] + == mv.source_list.itemWidget(mv.source_list.item(1)).source + ) # Now ensure the "multiple sources selected" view is shown mv.source_list.selectAll() - assert mv.empty_conversation_view.isVisible() - assert mv.empty_conversation_view.multi_source_selected.isVisible() + assert mv.view_layout.currentItem() == mv.MULTI_SELECTED_INDEX + def test_MainView_on_source_changed_does_not_raise_InvalidRequestError(mocker): """ @@ -709,6 +718,7 @@ def test_MainView_on_source_changed_does_not_raise_InvalidRequestError(mocker): assert mock_logger.debug.call_count == 1, mock_logger.debug.call_args + def test_MainView_on_source_changed_when_source_no_longer_exists(mocker): """ Test that conversation for a source is cleared when the source no longer exists. @@ -716,11 +726,12 @@ def test_MainView_on_source_changed_when_source_no_longer_exists(mocker): mv = MainView(None) mv.set_conversation = mocker.MagicMock() mv.source_list = mocker.MagicMock() + mv.source_list.selectedItems = mocker.MagicMock() mv.source_list.get_selected_source = mocker.MagicMock(return_value=None) mv.on_source_changed() - mv.source_list.get_selected_source.assert_called_once_with() + assert mv.view_layout.currentIndex() == mv.NOTHING_SELECTED_INDEX mv.set_conversation.assert_not_called() @@ -729,9 +740,12 @@ def test_MainView_on_source_changed_updates_conversation_view(mocker, session): Test that the source collection is displayed in the conversation view. """ mv = MainView(None) - # mv.source_list = mocker.MagicMock() - mv.controller = mocker.MagicMock(is_authenticated=True) source = factory.Source() + + mv.source_list = mocker.MagicMock() + mv.source_list.count = mocker.MagicMock(return_value=1) + mv.source_list.selectedItems = mocker.MagicMock(return_value=[source]) + mv.controller = mocker.MagicMock(is_authenticated=True) session.add(source) file = factory.File(source=source, filename="0-mock-doc.gpg") message = factory.Message(source=source, filename="0-mock-msg.gpg") @@ -769,16 +783,20 @@ def test_MainView_on_source_changed_SourceConversationWrapper_is_preserved(mocke SourceConversationWrapper when we click away from a given source. We should create it the first time, and then it should persist. """ - mv = MainView(None) - mv.set_conversation = mocker.MagicMock() - source_selected = mocker.patch("securedrop_client.gui.widgets.SourceList.source_selected") - mv.controller = mocker.MagicMock(is_authenticated=True) source = factory.Source() source2 = factory.Source() session.add(source) session.add(source2) session.commit() + mv = MainView(None) + mv.source_list = mocker.MagicMock() + mv.source_list.count = mocker.MagicMock(return_value=2) + mv.source_list.selectedItems = mocker.MagicMock() + mv.set_conversation = mocker.MagicMock() + source_selected = mocker.patch("securedrop_client.gui.widgets.SourceList.source_selected") + mv.controller = mocker.MagicMock(is_authenticated=True) + source_conversation_init = mocker.patch( "securedrop_client.gui.widgets.SourceConversationWrapper.__init__", return_value=None ) @@ -839,7 +857,8 @@ def test_MainView_refresh_source_conversations(homedir, mocker, qtbot, session_m source1 = factory.Source(uuid="rsc-123") session.add(source1) - source2 = factory.Source(uuid="rsc-456") + # Less recent update time (default is datetime.now()) + source2 = factory.Source(uuid="rsc-456", last_updated=(datetime.now() - timedelta(days=1))) session.add(source2) session.commit() @@ -856,29 +875,32 @@ def test_MainView_refresh_source_conversations(homedir, mocker, qtbot, session_m mv.source_list.update_sources(sources) mv.show() - # get the conversations created - mocker.patch( - "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source1 + # Inspect + mv.on_source_changed = mocker.MagicMock(wraps=mv.on_source_changed) + mv.source_list.itemSelectionChanged = mocker.MagicMock( + wraps=mv.source_list.itemSelectionChanged ) - mv.on_source_changed() - mocker.patch( - "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source2 - ) - mv.on_source_changed() + # Select one source, then another + mv.source_list.setCurrentRow(0) + + assert mv.source_list.get_selected_source() == source1 + # assert mv.on_source_changed.call_count == 1 + mv.source_list.setCurrentRow(1) + assert mv.source_list.get_selected_source() == source2 + # assert mv.on_source_changed.call_count == 2 assert len(mv.source_conversations) == 2 + # Nothing selected + mv.source_list.setCurrentRow(-1) + # refresh with no source selected - mocker.patch("securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=None) mv.refresh_source_conversations() + assert mv.view_layout.currentIndex() == mv.NO_SOURCES_INDEX - # refresh with source1 selected while its conversation is being deleted - mocker.patch( - "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source1 - ) - mv.on_source_changed() - + # # refresh with source1 selected while its conversation is being deleted + mv.source_list.setCurrentRow(0) assert len(mv.source_list.source_items) == 2 scw1 = mv.source_conversations[source1.uuid] @@ -968,31 +990,58 @@ def test_MainView_set_conversation(mocker): (i.e. that area of the screen on the right hand side). """ mv = MainView(None) - mv.view_layout = mocker.MagicMock() - mock_widget = mocker.MagicMock() - mv.set_conversation(mock_widget) + scw = SourceConversationWrapper(factory.Source(), mocker.MagicMock()) + mv.set_conversation(scw) + + assert mv.view_layout.widget(mv.CONVERSATION_INDEX) == scw + - mv.view_layout.takeAt.assert_called_once_with(0) - mv.view_layout.addWidget.assert_called_once_with(mock_widget) +def test_EmptyConversationView(mocker): + mv = MainView(None) + mv.source_list = mocker.MagicMock() + mv.source_list.count = mocker.MagicMock(return_value=0) + mv.source_list.selectedItems = mocker.MagicMock(return_value=[]) + mv.show() + assert mv.view_layout.count() == 4 # Sanity - are all the pages there? + mv.show_sources([]) + assert mv.view_layout.currentIndex() == mv.NO_SOURCES_INDEX + assert isinstance(mv.view_layout.widget(mv.view_layout.currentIndex()), EmptyConversationView) -def test_EmptyConversationView_show_no_sources_message(mocker): - ecv = EmptyConversationView() +def test_NothingSelectedView(mocker): + mv = MainView(None) + mv.show() + mv.source_list = mocker.MagicMock() + mv.source_list.count = mocker.MagicMock(return_value=4) + mv.source_list.selectedItems = mocker.MagicMock(return_value=[]) - ecv.show_no_sources_message() + # Sanity check - make sure that all base QStackedWidget pages + # (Empty, NothingSelected, MultiSelected, Conversation) are rendered + assert mv.view_layout.count() == 4 - assert not ecv.no_sources.isHidden() - assert ecv.no_source_selected.isHidden() + mv.show_sources([factory.Source(), factory.Source(), factory.Source()]) + assert isinstance(mv.view_layout.widget(mv.view_layout.currentIndex()), NothingSelectedView) + assert mv.view_layout.currentIndex() == mv.NOTHING_SELECTED_INDEX -def test_EmptyConversationView_show_no_source_selected_message(mocker): - ecv = EmptyConversationView() +def test_MultiSelectedView(mocker): + mv = MainView(None) + sources = [factory.Source(), factory.Source(), factory.Source()] + mv.source_list = mocker.MagicMock() + mv.source_list.count = mocker.MagicMock(return_value=len(sources)) + mv.source_list.selectedItems = mocker.MagicMock(return_value=sources[:-1]) + mv._on_update_conversation_context = mocker.MagicMock(wraps=mv._on_update_conversation_context) + mv.show_sources(sources) - ecv.show_no_source_selected_message() + mv.show() - assert ecv.no_sources.isHidden() - assert not ecv.no_source_selected.isHidden() + # Sanity check - make sure that all base QStackedWidget pages + # (Empty, NothingSelected, MultiSelected, Conversation) are rendered + assert mv.view_layout.count() == 4 + mv._on_update_conversation_context.assert_called() + assert isinstance(mv.view_layout.widget(mv.view_layout.currentIndex()), MultiSelectView) + assert mv.view_layout.currentIndex() == mv.MULTI_SELECTED_INDEX def test_SourceList_get_selected_source(mocker): @@ -3809,14 +3858,12 @@ def test_SourceConversationWrapper_on_source_deleted(mocker): mv.source_list = mocker.MagicMock() mv.source_list.get_selected_source = mocker.MagicMock(return_value=source) mv.controller = mocker.MagicMock(is_authenticated=True) - mv.show() + + # Detached sourceconversationwrapper, just for unit testing scw = SourceConversationWrapper(source, mv.controller, None) - mocker.patch("securedrop_client.gui.widgets.SourceConversationWrapper", return_value=scw) mv.on_source_changed() scw.on_source_deleted("123") - assert mv.isVisible() - assert scw.isVisible() assert not scw.conversation_title_bar.isHidden() assert not scw.reply_box.isHidden() assert not scw.reply_box.text_edit.isEnabled()