Skip to content

Commit

Permalink
no longer delete all source widgets each update
Browse files Browse the repository at this point in the history
  • Loading branch information
Allie Crevier committed Mar 9, 2020
1 parent 85f2bcd commit cb4d8dc
Show file tree
Hide file tree
Showing 4 changed files with 125 additions and 87 deletions.
50 changes: 24 additions & 26 deletions securedrop_client/gui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -839,33 +839,31 @@ def update(self, sources: List[Source]):
"""
Reset and update the list with the passed in list of sources.
"""
# A flag to show if a source is currently selected (the current source
# doesn't have to be selected in a QListWidget -- it appears to default
# to whatever is in row 0, whether it's selected or not).
has_source_selected = False
if self.currentRow() > -1:
has_source_selected = self.item(self.currentRow()).isSelected()
current_source = self.get_current_source()
current_source_id = current_source and current_source.id

# When we call clear() to delete all SourceWidgets, we should
# also clear the source_widgets dict.
self.clear()
self.source_widgets = {}

# Delete widgets that no longer exist in source list
source_uuids = [source.uuid for source in sources]
for i in range(self.count()):
list_item = self.item(i)
list_widget = self.itemWidget(list_item)

if list_widget and list_widget.source.uuid not in source_uuids:
if list_item.isSelected():
self.setCurrentItem(None)
del self.source_widgets[list_widget.source.uuid]
self.takeItem(i)
list_widget.deleteLater()

# Create new widgets for new sources
widget_uuids = [self.itemWidget(self.item(i)).source.uuid for i in range(self.count())]
for source in sources:
new_source = SourceWidget(source)
new_source.setup(self.controller)
self.source_widgets[source.uuid] = new_source

list_item = QListWidgetItem(self)
list_item.setSizeHint(new_source.sizeHint())

self.addItem(list_item)
self.setItemWidget(list_item, new_source)

if source.id == current_source_id and has_source_selected:
self.setCurrentItem(list_item)
if source.uuid not in widget_uuids:
new_source = SourceWidget(source)
new_source.setup(self.controller)
self.source_widgets[source.uuid] = new_source

list_item = QListWidgetItem()
self.insertItem(0, list_item)
list_item.setSizeHint(new_source.sizeHint())
self.setItemWidget(list_item, new_source)

def get_current_source(self):
source_item = self.currentItem()
Expand Down
2 changes: 1 addition & 1 deletion securedrop_client/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ def update_sources(self):
"""
sources = list(storage.get_local_sources(self.session))
if sources:
sources.sort(key=lambda x: x.last_updated, reverse=True)
sources.sort(key=lambda x: x.last_updated)
self.gui.show_sources(sources)

def on_update_star_success(self, result) -> None:
Expand Down
158 changes: 99 additions & 59 deletions tests/gui/test_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@

from securedrop_client import db, logic
from securedrop_client.export import ExportError, ExportStatus
from securedrop_client.gui.widgets import MainView, SourceList, SourceWidget, LoginDialog, \
SpeechBubble, MessageWidget, ReplyWidget, FileWidget, ConversationView, \
from securedrop_client.gui.widgets import MainView, SourceList, SourceWidget, SecureQLabel, \
LoginDialog, SpeechBubble, MessageWidget, ReplyWidget, FileWidget, ConversationView, \
DeleteSourceMessageBox, DeleteSourceAction, SourceMenu, TopPane, LeftPane, SyncIcon, \
ErrorStatusBar, ActivityStatusBar, UserProfile, UserButton, UserMenu, LoginButton, \
ReplyBoxWidget, ReplyTextEdit, SourceConversationWrapper, StarToggleButton, LoginOfflineLink, \
LoginErrorBar, EmptyConversationView, FramelessDialog, ExportDialog, PrintDialog, \
PasswordEdit, SecureQLabel, SourceProfileShortWidget
PasswordEdit, SourceProfileShortWidget
from tests import factory


Expand Down Expand Up @@ -680,15 +680,31 @@ def test_EmptyConversationView_show_no_source_selected_message(mocker):
'• Send a response\n')


def test_SourceList_update(mocker):
def test_SourceList_get_current_source(mocker):
"""
Check the items in the source list are cleared and a new SourceWidget for
each passed-in source is created along with an associated QListWidgetItem.
Maintains the selected item if present in new list
"""
sl = SourceList()
sl.controller = mocker.MagicMock()
sources = [factory.Source(), factory.Source()]
sl.update(sources)
sl.setCurrentItem(sl.itemAt(1, 0)) # select source2

current_source = sl.get_current_source()

assert current_source.id == sources[1].id


def test_SourceList_update_adds_new_sources(mocker):
"""
Check a new SourceWidget for each passed-in source is created and no widgets are cleared or
removed.
"""
sl = SourceList()

sl.clear = mocker.MagicMock()
sl.addItem = mocker.MagicMock()
sl.insertItem = mocker.MagicMock()
sl.takeItem = mocker.MagicMock()
sl.setItemWidget = mocker.MagicMock()
sl.controller = mocker.MagicMock()
sl.setCurrentItem = mocker.MagicMock()
Expand All @@ -701,91 +717,115 @@ def test_SourceList_update(mocker):
sources = [mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock(), ]
sl.update(sources)

sl.clear.assert_called_once_with()
assert mock_sw.call_count == len(sources)
assert mock_lwi.call_count == len(sources)
assert sl.addItem.call_count == len(sources)
assert sl.insertItem.call_count == len(sources)
assert sl.setItemWidget.call_count == len(sources)
assert len(sl.source_widgets) == len(sources)
assert sl.setCurrentItem.call_count == 0
sl.clear.assert_not_called()
sl.takeItem.assert_not_called()
mock_sw.deleteLater.assert_not_called()


def test_SourceList_update_deleted_source(mocker):
def test_SourceList_update_maintains_selection(mocker):
"""
When the SourceList is updated after deleting a source, check that we
do not keep references to deleted source widgets on the source_widgets
dict.
Maintains the selected item if present in new list
"""
sl = SourceList()

sl.clear = mocker.MagicMock()
sl.addItem = mocker.MagicMock()
sl.setItemWidget = mocker.MagicMock()
sl.controller = mocker.MagicMock()
sl.setCurrentItem = mocker.MagicMock()

mock_sw = mocker.MagicMock()
mock_lwi = mocker.MagicMock()
mocker.patch('securedrop_client.gui.widgets.SourceWidget', mock_sw)
mocker.patch('securedrop_client.gui.widgets.QListWidgetItem', mock_lwi)
sources = [factory.Source(), factory.Source()]
sl.update(sources)

sources = [mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock(), ]
sl.setCurrentItem(sl.itemAt(0, 0)) # select the first source
sl.update(sources)

sources.pop()
assert sl.currentItem()
assert sl.itemWidget(sl.currentItem()).source.id == sources[0].id

sl.setCurrentItem(sl.itemAt(1, 0)) # select the second source
sl.update(sources)

assert len(sl.source_widgets) == len(sources)
assert sl.currentItem()
assert sl.itemWidget(sl.currentItem()).source.id == sources[1].id


def test_SourceList_update_with_pre_selected_source(mocker):
def test_SourceList_update_with_pre_selected_source_maintains_selection(mocker):
"""
If there's a source already selected. It remains selected after update.
Check that an existing source widget that is selected remains selected.
"""
sl = SourceList()
sl.controller = mocker.MagicMock()
sl.update([factory.Source(), factory.Source()])
second_item = sl.itemAt(1, 0)
sl.setCurrentItem(second_item) # select the second source
mocker.patch.object(second_item, 'isSelected', return_value=True)

sl.clear = mocker.MagicMock()
sl.addItem = mocker.MagicMock()
sl.setItemWidget = mocker.MagicMock()
sl.update([factory.Source(), factory.Source()])

assert second_item.isSelected() is True


def test_SourceList_update_removes_selected_item_results_in_no_current_selection(mocker):
"""
Check that no items are currently selected if the currently selected item is deleted.
"""
sl = SourceList()
sl.controller = mocker.MagicMock()
sl.setCurrentItem = mocker.MagicMock()
sl.currentRow = mocker.MagicMock(return_value=1)
sl.item = mocker.MagicMock()
sl.item().isSelected.return_value = True
mock_source = mocker.MagicMock()
sl.get_current_source = mocker.MagicMock(return_value=mock_source)
sl.update([factory.Source(uuid='new'), factory.Source(uuid='newer')])

mock_sw = mocker.MagicMock()
mock_lwi = mocker.MagicMock()
mocker.patch('securedrop_client.gui.widgets.SourceWidget', mock_sw)
mocker.patch('securedrop_client.gui.widgets.QListWidgetItem', mock_lwi)
sl.setCurrentItem(sl.itemAt(0, 0)) # select source with uuid='newer'
sl.update([factory.Source(uuid='new')]) # delete source with uuid='newer'

sources = [mocker.MagicMock(), mock_source, mocker.MagicMock(), ]
sl.update(sources)
assert not sl.currentItem()

sl.clear.assert_called_once_with()
assert mock_sw.call_count == len(sources)
assert mock_lwi.call_count == len(sources)
assert sl.addItem.call_count == len(sources)
assert sl.setItemWidget.call_count == len(sources)
assert len(sl.source_widgets) == len(sources)
assert sl.setCurrentItem.call_count == 1

def test_SourceList_update_removes_item_from_end_of_list(mocker):
"""
Check that the item is removed from the source list and dict if the source no longer exists.
"""
sl = SourceList()
sl.controller = mocker.MagicMock()
sl.update([
factory.Source(uuid='new'), factory.Source(uuid='newer'), factory.Source(uuid='newest')])
assert sl.count() == 3
sl.update([factory.Source(uuid='newer'), factory.Source(uuid='newest')])
assert sl.count() == 2
assert sl.itemWidget(sl.item(0)).source.uuid == 'newest'
assert sl.itemWidget(sl.item(1)).source.uuid == 'newer'
assert len(sl.source_widgets) == 2

def test_SourceList_maintains_selection(mocker):

def test_SourceList_update_removes_item_from_middle_of_list(mocker):
"""
Maintains the selected item if present in new list
Check that the item is removed from the source list and dict if the source no longer exists.
"""
sl = SourceList()
sources = [factory.Source(), factory.Source()]
sl.setup(mocker.MagicMock())
sl.update(sources)
sl.controller = mocker.MagicMock()
sl.update([
factory.Source(uuid='new'), factory.Source(uuid='newer'), factory.Source(uuid='newest')])
assert sl.count() == 3
sl.update([factory.Source(uuid='new'), factory.Source(uuid='newest')])
assert sl.count() == 2
assert sl.itemWidget(sl.item(0)).source.uuid == 'newest'
assert sl.itemWidget(sl.item(1)).source.uuid == 'new'
assert len(sl.source_widgets) == 2

sl.setCurrentItem(sl.itemAt(0, 0))
sl.update(sources)

assert sl.currentItem()
assert sl.itemWidget(sl.currentItem()).source.id == sources[0].id
def test_SourceList_update_removes_item_from_beginning_of_list(mocker):
"""
Check that the item is removed from the source list and dict if the source no longer exists.
"""
sl = SourceList()
sl.controller = mocker.MagicMock()
sl.update([
factory.Source(uuid='new'), factory.Source(uuid='newer'), factory.Source(uuid='newest')])
assert sl.count() == 3
sl.update([factory.Source(uuid='new'), factory.Source(uuid='newer')])
assert sl.count() == 2
assert sl.itemWidget(sl.item(0)).source.uuid == 'newer'
assert sl.itemWidget(sl.item(1)).source.uuid == 'new'
assert len(sl.source_widgets) == 2


def test_SourceList_set_snippet(mocker):
Expand Down
2 changes: 1 addition & 1 deletion tests/test_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,7 @@ def test_Controller_update_sources(homedir, config, mocker):
co = Controller('http://localhost', mock_gui, mock_session_maker, homedir)

mock_storage = mocker.patch('securedrop_client.logic.storage')
source_list = [factory.Source(last_updated=2), factory.Source(last_updated=1)]
source_list = [factory.Source(last_updated=1), factory.Source(last_updated=2)]
mock_storage.get_local_sources.return_value = source_list

co.update_sources()
Expand Down

0 comments on commit cb4d8dc

Please sign in to comment.