diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 0d6538afcc..dd95b499b5 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -27,7 +27,7 @@ from typing import List from uuid import uuid4 -from securedrop_client.db import Source, Message, File, Reply +from securedrop_client.db import Source, Message, File from securedrop_client.gui import SvgLabel, SvgPushButton from securedrop_client.logic import Client from securedrop_client.resources import load_svg, load_icon, load_image @@ -62,7 +62,7 @@ def __init__(self): layout.setSpacing(0) # Refresh button - self.refresh = RefreshWidget() + self.refresh = RefreshButton() self.refresh.disable() # Activity status bar @@ -112,8 +112,63 @@ def clear_error_status(self): self.error_status_bar.clear_message() -class RefreshWidget(SvgPushButton): +class LeftPane(QWidget): + """ + Represents the left side pane that contains user authentication actions and information. + """ + + def __init__(self): + super().__init__() + + # Set layout + layout = QVBoxLayout(self) + self.setLayout(layout) + + # Remove margins and spacing + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(0) + + # Use a background gradient + palette = QPalette() + gradient = QLinearGradient(0, 0, 0, 700) + gradient.setColorAt(0, QColor('#0093da')) + gradient.setColorAt(1, QColor('#0c3e75')) + palette.setBrush(QPalette.Background, QBrush(gradient)) + self.setPalette(palette) + self.setAutoFillBackground(True) + + # User profile + self.user_profile = UserProfile() + + # Hide user profile widget until user logs in + self.user_profile.hide() + + # Add widgets to layout + layout.addWidget(self.user_profile) + + # Align content to the top of pane + layout.addStretch() + + def setup(self, window, controller): + self.user_profile.setup(window, controller) + + def set_logged_in_as(self, username): + """ + Update the UI to reflect that the user is logged in as "username". + """ + self.user_profile.set_username(username) + self.user_profile.show() + + def set_logged_out(self): + """ + Update the UI to a logged out state. + """ + self.user_profile.hide() + + +class RefreshButton(SvgPushButton): """ + A button that shows an icon for different refresh states. """ css = ''' @@ -151,18 +206,21 @@ def setup(self, controller): def _on_clicked(self): self.controller.sync_api() - # Set the icon's Qt.Normal state image to an active state image. This is a temporary fix to - # show that the icon was clicked. The icon image is later replaced in _on_refresh_complete. + # This is a temporary solution for showing the icon as active for the entire duration of a + # refresh, rather than for just the duration of a click. The icon image will be replaced + # when the controller tells us the refresh has finished. A cleaner solution would be to + # store and update our own icon mode so we don't have to reload any images. self.setIcon(load_icon( normal='refresh_active.svg', disabled='refresh_offline.svg')) - def _on_refresh_complete(self): - self.setIcon(load_icon( - normal='refresh.svg', - disabled='refresh_offline.svg', - active='refresh_active.svg', - selected='refresh.svg')) + def _on_refresh_complete(self, data): + if (data == 'synced'): + self.setIcon(load_icon( + normal='refresh.svg', + disabled='refresh_offline.svg', + active='refresh_active.svg', + selected='refresh.svg')) def enable(self): self.setEnabled(True) @@ -171,8 +229,41 @@ def disable(self): self.setEnabled(False) +class ActivityStatusBar(QStatusBar): + """ + A status bar for displaying messages about application activity to the user. Messages will be + displayed for a given duration or until the message updated with a new message. + """ + + css = ''' + #activity_status_bar { + color: #fff; + } + ''' + + def __init__(self): + super().__init__() + + # Set css id + self.setObjectName('activity_status_bar') + + # Set styles + self.setStyleSheet(self.css) + + # Remove grip image at bottom right-hand corner + self.setSizeGripEnabled(False) + + def update_message(self, message: str, duration: int): + """ + Display a status message to the user. + """ + self.showMessage(message, duration) + + class ErrorStatusBar(QWidget): """ + A pop-up status bar for displaying messages about application errors to the user. Messages will + be displayed for a given duration or until the message is cleared or updated with a new message. """ css = ''' @@ -276,89 +367,6 @@ def clear_message(self): self.status_bar.clearMessage() -class ActivityStatusBar(QStatusBar): - """ - """ - - css = ''' - #activity_status_bar { - color: #fff; - } - ''' - - def __init__(self): - super().__init__() - - # Set css id - self.setObjectName('activity_status_bar') - - # Set styles - self.setStyleSheet(self.css) - - # Remove grip image at bottom right-hand corner - self.setSizeGripEnabled(False) - - def update_message(self, message: str, duration: int): - """ - Display a status message to the user. - """ - self.showMessage(message, duration) - - -class LeftPane(QWidget): - """ - Represents the left side pane that contains user authentication actions and information. - """ - - def __init__(self): - super().__init__() - - # Set layout - layout = QVBoxLayout(self) - self.setLayout(layout) - - # Remove margins and spacing - layout.setContentsMargins(12, 12, 12, 12) - layout.setSpacing(0) - - # Use a background gradient - palette = QPalette() - gradient = QLinearGradient(0, 0, 0, 700) - gradient.setColorAt(0, QColor('#0093da')) - gradient.setColorAt(1, QColor('#0c3e75')) - palette.setBrush(QPalette.Background, QBrush(gradient)) - self.setPalette(palette) - self.setAutoFillBackground(True) - - # User profile - self.user_profile = UserProfile() - - # Hide user profile widget until user logs in - self.user_profile.hide() - - # Add widgets to layout - layout.addWidget(self.user_profile) - - # Align content to the top of pane - layout.addStretch() - - def setup(self, window, controller): - self.user_profile.setup(window, controller) - - def set_logged_in_as(self, username): - """ - Update the UI to reflect that the user is logged in as "username". - """ - self.user_profile.set_username(username) - self.user_profile.show() - - def set_logged_out(self): - """ - Update the UI to a logged out state. - """ - self.user_profile.hide() - - class UserProfile(QWidget): """ A widget that contains user profile information and options. @@ -439,8 +447,6 @@ class UserButton(SvgPushButton): css = ''' SvgPushButton#user_button { border: none; - padding-top: 12px; - padding-bottom: 12px; padding-left: 6px; background-color: #0093da; font-family: Open Sans; @@ -459,6 +465,7 @@ def __init__(self): super().__init__('dropdown_arrow.svg', svg_size=QSize()) self.setStyleSheet(self.css) + self.setFixedHeight(40) self.setObjectName('user_button') @@ -533,7 +540,7 @@ def __init__(self): # Set styles self.setStyleSheet(self.css) - self.setFixedSize(200, 40) + self.setFixedHeight(40) # Set drop shadow effect effect = QGraphicsDropShadowEffect(self) @@ -1256,9 +1263,11 @@ def __init__( self.conversation = ConversationView(self.source, self.sdc_home, self.controller, parent=self) self.source_profile = SourceProfileShortWidget(self.source, self.controller) + self.reply_box = ReplyBoxWidget(self) self.layout.addWidget(self.source_profile, 1) self.layout.addWidget(self.conversation, 9) + self.layout.addWidget(self.reply_box, 3) self.controller.authentication_state.connect(self._show_or_hide_replybox) self._show_or_hide_replybox(is_authenticated) @@ -1269,21 +1278,13 @@ def send_reply(self, message: str) -> None: self.controller.send_reply(self.source.uuid, msg_uuid, message) def _show_or_hide_replybox(self, show: bool) -> None: - if show: - new_widget = ReplyBoxWidget(self) - else: - new_widget = ReplyBoxWidget(self) - new_widget.text_edit.setText(_('You need to log in to send replies.')) - new_widget.text_edit.setEnabled(False) - new_widget.send_button.hide() + if not show: + self.reply_box.disable() old_widget = self.layout.takeAt(2) if old_widget is not None: old_widget.widget().deleteLater() - self.reply_box = new_widget - self.layout.addWidget(new_widget, 3) - class ReplyBoxWidget(QWidget): """ @@ -1322,6 +1323,15 @@ def send_reply(self) -> None: self.conversation.send_reply(msg) self.text_edit.clear() + def enable(self): + self.text_edit.setEnabled(True) + self.send_button.show() + + def disable(self): + self.text_edit.setText(_('You need to log in to send replies.')) + self.text_edit.setEnabled(False) + self.send_button.hide() + class DeleteSourceAction(QAction): """Use this action to delete the source record.""" diff --git a/tests/gui/test_main.py b/tests/gui/test_main.py index eca64b579e..4f8408f4dc 100644 --- a/tests/gui/test_main.py +++ b/tests/gui/test_main.py @@ -19,14 +19,14 @@ def test_init(mocker): mock_lo().addWidget = mocker.MagicMock() mocker.patch('securedrop_client.gui.main.load_icon', mock_li) - mock_tb = mocker.patch('securedrop_client.gui.main.ToolBar') + mock_lp = mocker.patch('securedrop_client.gui.main.LeftPane') mock_mv = mocker.patch('securedrop_client.gui.main.MainView') mocker.patch('securedrop_client.gui.main.QHBoxLayout', mock_lo) mocker.patch('securedrop_client.gui.main.QMainWindow') w = Window('mock') mock_li.assert_called_once_with(w.icon) - mock_tb.assert_called_once_with(w.widget) + mock_lp.assert_called_once_with() mock_mv.assert_called_once_with(w.widget) assert mock_lo().addWidget.call_count == 2 @@ -66,15 +66,13 @@ def test_show_login(mocker): mock_controller = mocker.MagicMock() w = Window('mock') w.setup(mock_controller) - w.status_bar = mocker.MagicMock() - mock_ld = mocker.patch('securedrop_client.gui.main.LoginDialog') + w.show_login() mock_ld.assert_called_once_with(w) w.login_dialog.reset.assert_called_once_with() w.login_dialog.exec.assert_called_once_with() - w.status_bar.show_refresh_icon.assert_called_once_with() def test_show_login_error(mocker): @@ -113,16 +111,60 @@ def test_show_sources(mocker): w.main_view.source_list.update.assert_called_once_with([1, 2, 3]) +def test_update_error_status_default(mocker): + """ + Ensure that the error to be shown in the error status bar will be passed to the top pane with a + default duration of 10 seconds. + """ + w = Window('mock') + w.top_pane = mocker.MagicMock() + w.update_error_status(message='test error message') + w.top_pane.update_error_status.assert_called_once_with('test error message', 10000) + + def test_update_error_status(mocker): """ - Ensure that the error to be shown in the error status sidebar will - be passed to the left sidebar for display. + Ensure that the error to be shown in the error status bar will be passed to the top pane with + the duration of seconds provided. """ - error_message = "this is a bad thing!" w = Window('mock') - w.main_view = mocker.MagicMock() - w.update_error_status(error=error_message) - w.main_view.update_error_status.assert_called_once_with(error_message) + w.top_pane = mocker.MagicMock() + w.update_error_status(message='test error message', duration=123) + w.top_pane.update_error_status.assert_called_once_with('test error message', 123) + + +def test_update_activity_status_default(mocker): + """ + Ensure that the activity to be shown in the activity status bar will be passed to the top pane + with a default duration of 0 seconds, i.e. forever. + """ + w = Window('mock') + w.top_pane = mocker.MagicMock() + w.update_activity_status(message='test message') + w.top_pane.update_activity_status.assert_called_once_with('test message', 0) + + +def test_update_activity_status(mocker): + """ + Ensure that the activity to be shown in the activity status bar will be passed to the top pane + with the duration of seconds provided. + """ + w = Window('mock') + w.top_pane = mocker.MagicMock() + w.update_activity_status(message='test message', duration=123) + w.top_pane.update_activity_status.assert_called_once_with('test message', 123) + + +def test_clear_error_status(mocker): + """ + Ensure clear_error_status is called. + """ + w = Window('mock') + w.top_pane = mocker.MagicMock() + + w.clear_error_status() + + w.top_pane.clear_error_status.assert_called_once_with() def test_show_sync(mocker): @@ -130,10 +172,11 @@ def test_show_sync(mocker): If there's a value display the result of its "humanize" method.humanize """ w = Window('mock') - w.set_status = mocker.MagicMock() + w.update_activity_status = mocker.MagicMock() updated_on = mocker.MagicMock() w.show_sync(updated_on) - w.set_status.assert_called_once_with('Last refresh: {}'.format(updated_on.humanize())) + w.update_activity_status.assert_called_once_with( + 'Last refresh: {}'.format(updated_on.humanize())) def test_show_sync_no_sync(mocker): @@ -141,31 +184,33 @@ def test_show_sync_no_sync(mocker): If there's no value to display, default to a "waiting" message. """ w = Window('mock') - w.set_status = mocker.MagicMock() + w.update_activity_status = mocker.MagicMock() w.show_sync(None) - w.set_status.assert_called_once_with('Waiting to refresh...', 5000) + w.update_activity_status.assert_called_once_with('Waiting to refresh...', 5000) def test_set_logged_in_as(mocker): """ - Given a username, the toolbar is appropriately called to update. + Given a username, the left pane is appropriately called to update. """ w = Window('mock') - w.tool_bar = mocker.MagicMock() + w.left_pane = mocker.MagicMock() w.set_logged_in_as('test') - w.tool_bar.set_logged_in_as.assert_called_once_with('test') + w.left_pane.set_logged_in_as.assert_called_once_with('test') def test_logout(mocker): """ - Ensure the toolbar updates to the logged out state. + Ensure the left pane updates to the logged out state. """ w = Window('mock') - w.tool_bar = mocker.MagicMock() - w.status_bar = mocker.MagicMock() + w.left_pane = mocker.MagicMock() + w.top_pane = mocker.MagicMock() + w.logout() - w.tool_bar.set_logged_out.assert_called_once_with() - w.status_bar.hide_refresh_icon.assert_called_once_with() + + w.left_pane.set_logged_out.assert_called_once_with() + w.top_pane.disable_refresh.assert_called_once_with() def test_on_source_changed(mocker): @@ -265,13 +310,3 @@ def test_conversation_pending_message(mocker): assert mocked_add_message.call_count == 1 assert mocked_add_message.call_args == mocker.call(message) - - -def test_set_status(mocker): - """ - Ensure the status bar's text is updated. - """ - w = Window('mock') - w.status_bar = mocker.MagicMock() - w.set_status('hello', 100) - w.status_bar.show_message.assert_called_once_with('hello', 100) diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 9ef603769a..b9d1a20a62 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -2,167 +2,372 @@ Make sure the UI widgets are configured correctly and work as expected. """ from PyQt5.QtWidgets import QWidget, QApplication, QWidgetItem, QSpacerItem, QVBoxLayout, \ - QMessageBox, QLabel + QMessageBox, QLabel, QTextEdit from tests import factory -from securedrop_client import db -from securedrop_client import logic -from securedrop_client.gui.widgets import ToolBar, MainView, SourceList, SourceWidget, \ - LoginDialog, SpeechBubble, ConversationWidget, MessageWidget, ReplyWidget, FileWidget, \ - ConversationView, DeleteSourceMessageBox, DeleteSourceAction, SourceMenu, TopPane, \ - SourceConversationWrapper, ReplyBoxWidget, JournalistMenu +from securedrop_client import db, logic +from securedrop_client.gui.widgets import MainView, SourceList, SourceWidget, LoginDialog, \ + SpeechBubble, ConversationWidget, MessageWidget, ReplyWidget, FileWidget, ConversationView, \ + DeleteSourceMessageBox, DeleteSourceAction, SourceMenu, TopPane, LeftPane, RefreshButton, \ + ErrorStatusBar, ActivityStatusBar, UserProfile, UserButton, UserMenu, LoginButton, \ + ReplyBoxWidget, SourceConversationWrapper app = QApplication([]) -def test_ToolBar_init(): +def test_TopPane_init(mocker): """ - Ensure the ToolBar instance is correctly set up. + Ensure the TopPane instance is correctly set up. """ - tb = ToolBar(None) - assert not tb.login.isHidden() - assert tb.user_icon.isHidden() - assert tb.user_state.isHidden() - assert tb.user_menu.isHidden() + tp = TopPane() + assert not tp.refresh.isEnabled() -def test_ToolBar_setup(mocker): +def test_TopPane_setup(mocker): """ - Calling setup with references to a window and controller object results in - them becoming attributes of self. + Calling setup calls setup for RefreshButton. """ - tb = ToolBar(None) - mock_window = mocker.MagicMock() + tp = TopPane() + tp.refresh = mocker.MagicMock() mock_controller = mocker.MagicMock() - tb.setup(mock_window, mock_controller) + tp.setup(mock_controller) - assert tb.window == mock_window - assert tb.controller == mock_controller + tp.refresh.setup.assert_called_once_with(mock_controller) -def test_ToolBar_set_logged_in_as(mocker): +def test_TopPane_enable_refresh(mocker): """ - When a user is logged in check that buttons and menus are in the correct state. + Calling enable_refresh calls enable on RefreshButton. """ - tb = ToolBar(None) - tb.login = mocker.MagicMock() - tb.user_icon = mocker.MagicMock() - tb.user_state = mocker.MagicMock() - tb.user_menu = mocker.MagicMock() + tp = TopPane() + tp.refresh = mocker.MagicMock() - tb.set_logged_in_as('test') + tp.enable_refresh() - tb.user_state.setText.assert_called_once_with('test') - tb.login.hide.assert_called_once_with() - tb.user_icon.show.assert_called_once_with() - tb.user_state.show.assert_called_once_with() - tb.user_menu.show.assert_called_once_with() + tp.refresh.enable.assert_called_once_with() -def test_ToolBar_set_logged_out(mocker): +def test_TopPane_disable_refresh(mocker): """ - When a user is logged out check that buttons and menus are in the correct state. + Calling disable_refresh calls disable on RefreshButton. """ - tb = ToolBar(None) - tb.login = mocker.MagicMock() - tb.user_icon = mocker.MagicMock() - tb.user_state = mocker.MagicMock() - tb.user_menu = mocker.MagicMock() + tp = TopPane() + tp.refresh = mocker.MagicMock() - tb.set_logged_out() + tp.disable_refresh() - tb.login.show.assert_called_once_with() - tb.user_icon.hide.assert_called_once_with() - tb.user_state.hide.assert_called_once_with() - tb.user_menu.hide.assert_called_once_with() + tp.refresh.disable.assert_called_once_with() -def test_ToolBar_on_login_clicked(mocker): +def test_TopPane_update_activity_status(mocker): """ - When login button is clicked, the window activates the login form. + Calling update_activity_status calls update_message on ActivityStatusBar. """ - tb = ToolBar(None) - tb.window = mocker.MagicMock() - tb.on_login_clicked() - tb.window.show_login.assert_called_once_with() + tp = TopPane() + tp.activity_status_bar = mocker.MagicMock() + tp.update_activity_status(message='test message', duration=5) -def test_ToolBar_on_logout_clicked(mocker): + tp.activity_status_bar.update_message.assert_called_once_with('test message', 5) + + +def test_TopPane_update_error_status(mocker): """ - When logout is clicked, the logout logic from the controller is started. + Calling update_error_status calls update_message on ErrorStatusBar. """ - tb = ToolBar(None) - tb.controller = mocker.MagicMock() - tb.on_logout_clicked() - tb.controller.logout.assert_called_once_with() + tp = TopPane() + tp.error_status_bar = mocker.MagicMock() + + tp.update_error_status(message='test message', duration=5) + + tp.error_status_bar.update_message.assert_called_once_with('test message', 5) -def test_JournalistMenu_on_logout_clicked_action_triggered(mocker): +def test_TopPane_clear_error_status(mocker): """ - When the sign-out option is selected, call on_logout_clicked. + Calling clear_error_status calls clear_message on RefreshButton. """ - tb = ToolBar(None) - tb.controller = mocker.MagicMock() - jm = JournalistMenu(tb) - jm.actions()[0].trigger() - tb.controller.logout.assert_called_once_with() + tp = TopPane() + tp.error_status_bar = mocker.MagicMock() + tp.clear_error_status() -def test_TopPane_on_refresh_clicked(mocker): + tp.error_status_bar.clear_message.assert_called_once_with() + + +def test_LeftPane_init(mocker): """ - When refresh is clicked, the refresh logic from the controller is stated. + Ensure the LeftPane instance is correctly set up. """ - sb = TopPane() - sb.controller = mocker.MagicMock() - sb.on_refresh_clicked() - sb.controller.sync_api.assert_called_once_with() + lp = LeftPane() + lp.user_profile = mocker.MagicMock() + assert lp.user_profile.isHidden() -def test_TopPane_sync_event(): - """Toggles refresh button when syncing +def test_LeftPane_setup(mocker): """ - sb = TopPane() - sb._on_sync_event('syncing') - assert not sb.refresh.isEnabled() + Calling setup calls setup for UserProfile. + """ + lp = LeftPane() + lp.user_profile = mocker.MagicMock() + mock_window = mocker.MagicMock() + mock_controller = mocker.MagicMock() - sb._on_sync_event('synced') - assert sb.refresh.isEnabled() + lp.setup(mock_window, mock_controller) + lp.user_profile.setup.assert_called_once_with(mock_window, mock_controller) -def test_TopPane_init(mocker): + +def test_LeftPane_set_logged_in_as(mocker): """ - Ensure the TopPane instance is correctly set up. + When a user is logged in check that buttons and menus are in the correct state. """ - tb = ToolBar(None) - mock_window = mocker.MagicMock() - mock_controller = mocker.MagicMock() - tb.setup(mock_window, mock_controller) + lp = LeftPane() + lp.user_profile = mocker.MagicMock() + + lp.set_logged_in_as('test') + + lp.user_profile.show.assert_called_once_with() + lp.user_profile.set_username.assert_called_once_with('test') + + +def test_LeftPane_set_logged_out(mocker): + """ + When a user is logged out check that buttons and menus are in the correct state. + """ + lp = LeftPane() + lp.user_profile = mocker.MagicMock() + + lp.set_logged_out() + + lp.user_profile.hide.assert_called_once_with() + + +def test_RefreshButton_setup(mocker): + """ + Calling setup stores reference to controller, which will later be used to update button icon on + sync event. + """ + rb = RefreshButton() + controller = mocker.MagicMock() - sb = TopPane() - sb.setup(mock_controller) + rb.setup(controller) - assert not sb.refresh.isHidden() + assert rb.controller == controller -def test_TopPane_show_refresh(mocker): +def test_RefreshButton_on_clicked(mocker): """ - Ensure the TopPane shows refresh icon. + When refresh button is clicked, sync_api should be called. """ - sb = TopPane() - sb.refresh = mocker.MagicMock() - sb.show_refresh_icon() - sb.refresh.show.assert_called_once_with() + rb = RefreshButton() + rb.controller = mocker.MagicMock() + + rb._on_clicked() + + rb.controller.sync_api.assert_called_once_with() + + +def test_RefreshButton_on_refresh_complete(mocker): + """ + Make sure we are enabled after a refresh completes. + """ + rb = RefreshButton() + rb._on_refresh_complete('synced') + assert rb.isEnabled() + + +def test_RefreshButton_enable(mocker): + rb = RefreshButton() + rb.enable() + assert rb.isEnabled() + + +def test_RefreshButton_disable(mocker): + rb = RefreshButton() + rb.disable() + assert not rb.isEnabled() + + +def test_ErrorStatusBar_clear_error_status(mocker): + """ + Calling clear_error_status calls clear_message on RefreshButton. + """ + esb = ErrorStatusBar() + esb.status_bar = mocker.MagicMock() + + esb.clear_message() + + esb.status_bar.clearMessage.assert_called_once_with() + + +def test_ErrorStatusBar_update_message(mocker): + """ + Calling update_message updates the message of the QStatusBar and starts the a timer for the + given duration. + """ + esb = ErrorStatusBar() + esb.status_bar = mocker.MagicMock() + esb.status_timer = mocker.MagicMock() + + esb.update_message(message='test message', duration=123) + + esb.status_bar.showMessage.assert_called_once_with('test message', 123) + esb.status_timer.start.assert_called_once_with(123) + + +def test_ErrorStatusBar_hide(mocker): + esb = ErrorStatusBar() + esb.vertical_bar = mocker.MagicMock() + esb.label = mocker.MagicMock() + esb.status_bar = mocker.MagicMock() + + esb._hide() + + esb.vertical_bar.hide.assert_called_once_with() + esb.label.hide.assert_called_once_with() + esb.status_bar.hide.assert_called_once_with() + + +def test_ErrorStatusBar_show(mocker): + esb = ErrorStatusBar() + esb.vertical_bar = mocker.MagicMock() + esb.label = mocker.MagicMock() + esb.status_bar = mocker.MagicMock() + + esb._show() + + esb.vertical_bar.show.assert_called_once_with() + esb.label.show.assert_called_once_with() + esb.status_bar.show.assert_called_once_with() + + +def test_ErrorStatusBar_on_status_timeout(mocker): + esb = ErrorStatusBar() + esb._on_status_timeout() + assert esb.isHidden() -def test_TopPane_hide_refresh(mocker): +def test_ActivityStatusBar_update_message(mocker): """ - Ensure the TopPane hides refresh icon. + Calling update_message updates the message of the QStatusBar. """ - sb = TopPane() - sb.refresh = mocker.MagicMock() - sb.hide_refresh_icon() - sb.refresh.hide.assert_called_once_with() + asb = ActivityStatusBar() + asb.update_message(message='test message', duration=123) + assert asb.currentMessage() == 'test message' + + +def test_UserProfile_setup(mocker): + up = UserProfile() + up.user_button = mocker.MagicMock() + up.login_button = mocker.MagicMock() + window = mocker.MagicMock() + controller = mocker.MagicMock() + + up.setup(window, controller) + + up.user_button.setup.assert_called_once_with(controller) + up.login_button.setup.assert_called_once_with(window) + + +def test_UserProfile_set_username(mocker): + up = UserProfile() + up.user_icon = mocker.MagicMock() + up.user_button = mocker.MagicMock() + + up.set_username('test_username') + + up.user_icon.setText.assert_called_once_with('jo') # testing current behavior as placeholder + up.user_button.set_username.assert_called_once_with('test_username') + + +def test_UserProfile_show(mocker): + up = UserProfile() + up.user_icon = mocker.MagicMock() + up.user_button = mocker.MagicMock() + up.login_button = mocker.MagicMock() + + up.show() + + up.login_button.hide.assert_called_once_with() + up.user_icon.show.assert_called_once_with() + up.user_button.show.assert_called_once_with() + + +def test_UserProfile_hide(mocker): + up = UserProfile() + up.user_icon = mocker.MagicMock() + up.user_button = mocker.MagicMock() + up.login_button = mocker.MagicMock() + + up.hide() + + up.user_icon.hide.assert_called_once_with() + up.user_button.hide.assert_called_once_with() + up.login_button.show.assert_called_once_with() + + +def test_UserButton_setup(mocker): + ub = UserButton() + ub.menu = mocker.MagicMock() + controller = mocker.MagicMock() + + ub.setup(controller) + + ub.menu.setup.assert_called_once_with(controller) + + +def test_UserButton_set_username(): + ub = UserButton() + ub.set_username('test_username') + ub.text() == 'test_username' + + +def test_UserMenu_setup(mocker): + um = UserMenu() + controller = mocker.MagicMock() + + um.setup(controller) + + assert um.controller == controller + + +def test_UserMenu_on_logout_triggered(mocker): + um = UserMenu() + um.controller = mocker.MagicMock() + + um._on_logout_triggered() + + um.controller.logout.assert_called_once_with() + + +def test_UserMenu_on_item_selected(mocker): + um = UserMenu() + um.controller = mocker.MagicMock() + + um.actions()[0].trigger() + + um.controller.logout.assert_called_once_with() + + +def test_LoginButton_init(mocker): + lb = LoginButton() + assert lb.text() == 'SIGN IN' + + +def test_LoginButton_setup(mocker): + lb = LoginButton() + window = mocker.MagicMock() + lb.setup(window) + lb.window = window + + +def test_Loginbutton_on_clicked(mocker): + lb = LoginButton() + lb.window = mocker.MagicMock() + lb._on_clicked() + lb.window.show_login.assert_called_once_with() def test_MainView_init(): @@ -189,21 +394,6 @@ def test_MainView_show_conversation(mocker): mv.view_layout.addWidget.assert_called_once_with(mock_widget) -def test_MainView_update_error_status(mocker): - """ - Ensure when the update_error_status method is called on the MainView that - the error status text is set as expected. - """ - mv = MainView(None) - expected_message = "this is the message to be displayed" - - mv.error_status = mocker.MagicMock() - mv.error_status.setText = mocker.MagicMock() - - mv.update_error_status(error=expected_message) - mv.error_status.setText.assert_called_once_with(expected_message) - - def test_SourceList_update(mocker): """ Check the items in the source list are cleared and a new SourceWidget for @@ -1323,6 +1513,32 @@ def test_ReplyWidget_success_failure_slots(mocker): assert mock_logger.debug.called +def test_ReplyBoxWidget_enable(mocker): + mock_conversation = mocker.Mock() + rw = ReplyBoxWidget(mock_conversation) + rw.text_edit = QTextEdit() + rw.send_button = mocker.MagicMock() + + rw.enable() + + assert rw.text_edit.isEnabled() + assert rw.text_edit.toPlainText() == '' + rw.send_button.show.assert_called_once_with() + + +def test_ReplyBoxWidget_disable(mocker): + mock_conversation = mocker.Mock() + rw = ReplyBoxWidget(mock_conversation) + rw.text_edit = QTextEdit() + rw.send_button = mocker.MagicMock() + + rw.disable() + + assert not rw.text_edit.isEnabled() + assert rw.text_edit.toPlainText() == 'You need to log in to send replies.' + rw.send_button.hide.assert_called_once_with() + + def test_update_conversation_maintains_old_items(mocker, homedir, session): """ Calling update_conversation deletes and adds old items back to layout @@ -1416,22 +1632,11 @@ def test_SourceConversationWrapper_set_widgets_via_auth_value(mocker, homedir): """ When the client is authenticated, we should create a ReplyBoxWidget otherwise a warning. """ - mock_source = mocker.Mock(collection=[]) - mock_controller = mocker.MagicMock() - - mocker.patch('securedrop_client.gui.widgets.LastUpdatedLabel', return_value=QLabel('now')) - cw = SourceConversationWrapper(mock_source, 'mock home', mock_controller, True) - mocker.patch.object(cw, 'layout') - mock_reply_box = mocker.patch('securedrop_client.gui.widgets.ReplyBoxWidget', - return_value=QWidget()) - mock_label = mocker.patch('securedrop_client.gui.widgets.QLabel', return_value=QWidget()) - - cw._show_or_hide_replybox(True) - mock_reply_box.assert_called_once_with(cw) - assert not mock_label.called + source = mocker.MagicMock() + controller = mocker.MagicMock() + scw = SourceConversationWrapper(source, 'mock home', controller, True) + scw.reply_box = mocker.MagicMock() - mock_reply_box.reset_mock() + scw._show_or_hide_replybox(False) - cw._show_or_hide_replybox(False) - assert not mock_reply_box.called - assert mock_label.called + scw.reply_box.disable.assert_called_once_with() diff --git a/tests/test_logic.py b/tests/test_logic.py index 57c9987af6..e2259d1ada 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -275,7 +275,7 @@ def test_Client_on_authenticate_ok(homedir, config, mocker): cl.start_message_thread.assert_called_once_with() cl.gui.set_logged_in_as.assert_called_once_with('test') # Error status bar should be cleared - cl.gui.update_error_status.assert_called_once_with("") + cl.gui.clear_error_status.assert_called_once_with() def test_Client_completed_api_call_without_current_object(homedir, config, mocker): @@ -760,7 +760,7 @@ def test_Client_unstars_a_source_if_starred(homedir, config, mocker): cl.call_api.assert_called_once_with( cl.api.remove_star, cl.on_update_star_complete, cl.on_sidebar_action_timeout, source_sdk_object) - mock_gui.update_error_status.assert_called_once_with("") + mock_gui.clear_error_status.assert_called_once_with() def test_Client_unstars_a_source_if_unstarred(homedir, config, mocker): @@ -786,7 +786,7 @@ def test_Client_unstars_a_source_if_unstarred(homedir, config, mocker): cl.call_api.assert_called_once_with( cl.api.add_star, cl.on_update_star_complete, cl.on_sidebar_action_timeout, source_sdk_object) - mock_gui.update_error_status.assert_called_once_with("") + mock_gui.clear_error_status.assert_called_once_with() def test_Client_update_star_not_logged_in(homedir, config, mocker): @@ -832,7 +832,7 @@ def test_Client_on_update_star_success(homedir, config, mocker): cl.sync_api = mocker.MagicMock() cl.on_update_star_complete(result) cl.sync_api.assert_called_once_with() - mock_gui.update_error_status.assert_called_once_with("") + mock_gui.clear_error_status.assert_called_once_with() def test_Client_on_update_star_failed(homedir, config, mocker): @@ -874,7 +874,7 @@ def test_Client_logout(homedir, config, mocker): cl.gui.logout.assert_called_once_with() -def test_Client_set_status(homedir, config, mocker): +def test_Client_set_activity_status(homedir, config, mocker): """ Ensure the GUI set_status API is called. Using the `config` fixture to ensure the config is written to disk. @@ -883,7 +883,7 @@ def test_Client_set_status(homedir, config, mocker): mock_session = mocker.MagicMock() cl = Client('http://localhost', mock_gui, mock_session, homedir) cl.set_status("Hello, World!", 1000) - mock_gui.set_status.assert_called_once_with("Hello, World!", 1000) + mock_gui.update_activity_status.assert_called_once_with("Hello, World!", 1000) PERMISSIONS_CASES = [ @@ -1162,7 +1162,7 @@ def test_Client_on_delete_source_complete_with_results(homedir, config, mocker): cl.sync_api = mocker.MagicMock() cl._on_delete_source_complete(True) cl.sync_api.assert_called_with() - cl.gui.update_error_status.assert_called_with("") + cl.gui.clear_error_status.assert_called_with() def test_Client_on_delete_source_complete_without_results(homedir, config, mocker):