Merge lp:~phill-ridout/openlp/ftw-json-theme-list into lp:openlp

Proposed by Phill
Status: Merged
Merged at revision: 2846
Proposed branch: lp:~phill-ridout/openlp/ftw-json-theme-list
Merge into: lp:openlp
Diff against target: 961 lines (+395/-269)
6 files modified
openlp/core/common/httputils.py (+47/-1)
openlp/core/ui/firsttimeform.py (+118/-186)
openlp/core/ui/firsttimewizard.py (+50/-15)
openlp/core/ui/icons.py (+2/-0)
tests/functional/openlp_core/ui/test_firsttimeform.py (+90/-67)
tests/interfaces/openlp_core/ui/test_firsttimeform.py (+88/-0)
To merge this branch: bzr merge lp:~phill-ridout/openlp/ftw-json-theme-list
Reviewer Review Type Date Requested Status
Tomas Groth Approve
Raoul Snyman Pending
Review via email: mp+363517@code.launchpad.net

This proposal supersedes a proposal from 2019-02-16.

Commit message

move ftw to new json config format. spruce up theme list page

To post a comment you must log in.
Revision history for this message
Raoul Snyman (raoul-snyman) wrote : Posted in a previous version of this proposal

Linux tests passed!

Revision history for this message
Raoul Snyman (raoul-snyman) wrote : Posted in a previous version of this proposal

Linting passed!

Revision history for this message
Raoul Snyman (raoul-snyman) wrote : Posted in a previous version of this proposal

macOS tests passed!

Revision history for this message
Raoul Snyman (raoul-snyman) wrote : Posted in a previous version of this proposal

Linux tests passed!

Revision history for this message
Raoul Snyman (raoul-snyman) wrote : Posted in a previous version of this proposal

Linting passed!

Revision history for this message
Raoul Snyman (raoul-snyman) wrote : Posted in a previous version of this proposal

macOS tests passed!

Revision history for this message
Raoul Snyman (raoul-snyman) wrote : Posted in a previous version of this proposal

This looks good, though I'm not a fan of f"{Strings}" (I prefer to follow the mantra of "explicit is better than implicit").

One small change to remove a #noqa below, and we're good to go.

review: Needs Fixing
Revision history for this message
Raoul Snyman (raoul-snyman) wrote :

Linux tests passed!

Revision history for this message
Raoul Snyman (raoul-snyman) wrote :

Linting passed!

Revision history for this message
Raoul Snyman (raoul-snyman) wrote :

macOS tests passed!

Revision history for this message
Tomas Groth (tomasgroth) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'openlp/core/common/httputils.py'
2--- openlp/core/common/httputils.py 2019-02-14 15:09:09 +0000
3+++ openlp/core/common/httputils.py 2019-02-21 21:32:43 +0000
4@@ -27,12 +27,16 @@
5 import sys
6 import time
7 from random import randint
8+from tempfile import gettempdir
9
10 import requests
11+from PyQt5 import QtCore
12
13 from openlp.core.common import trace_error_handler
14+from openlp.core.common.path import Path
15 from openlp.core.common.registry import Registry
16 from openlp.core.common.settings import ProxyMode, Settings
17+from openlp.core.threading import ThreadWorker
18
19
20 log = logging.getLogger(__name__ + '.__init__')
21@@ -227,4 +231,46 @@
22 return True
23
24
25-__all__ = ['get_web_page']
26+class DownloadWorker(ThreadWorker):
27+ """
28+ This worker allows a file to be downloaded in a thread
29+ """
30+ download_failed = QtCore.pyqtSignal()
31+ download_succeeded = QtCore.pyqtSignal(Path)
32+
33+ def __init__(self, base_url, file_name):
34+ """
35+ Set up the worker object
36+ """
37+ self._base_url = base_url
38+ self._file_name = file_name
39+ self._download_cancelled = False
40+ super().__init__()
41+
42+ def start(self):
43+ """
44+ Download the url to the temporary directory
45+ """
46+ if self._download_cancelled:
47+ self.quit.emit()
48+ return
49+ try:
50+ dest_path = Path(gettempdir()) / 'openlp' / self._file_name
51+ url = '{url}{name}'.format(url=self._base_url, name=self._file_name)
52+ is_success = download_file(self, url, dest_path)
53+ if is_success and not self._download_cancelled:
54+ self.download_succeeded.emit(dest_path)
55+ else:
56+ self.download_failed.emit()
57+ except Exception:
58+ log.exception('Unable to download %s', url)
59+ self.download_failed.emit()
60+ finally:
61+ self.quit.emit()
62+
63+ @QtCore.pyqtSlot()
64+ def cancel_download(self):
65+ """
66+ A slot to allow the download to be cancelled from outside of the thread
67+ """
68+ self._download_cancelled = True
69
70=== modified file 'openlp/core/ui/firsttimeform.py'
71--- openlp/core/ui/firsttimeform.py 2019-02-14 15:09:09 +0000
72+++ openlp/core/ui/firsttimeform.py 2019-02-21 21:32:43 +0000
73@@ -22,19 +22,19 @@
74 """
75 This module contains the first time wizard.
76 """
77+import json
78 import logging
79 import time
80 import urllib.error
81 import urllib.parse
82 import urllib.request
83-from configparser import ConfigParser, MissingSectionHeaderError, NoOptionError, NoSectionError
84 from tempfile import gettempdir
85
86 from PyQt5 import QtCore, QtWidgets
87
88 from openlp.core.common import clean_button_text, trace_error_handler
89 from openlp.core.common.applocation import AppLocation
90-from openlp.core.common.httputils import download_file, get_url_file_size, get_web_page
91+from openlp.core.common.httputils import DownloadWorker, download_file, get_url_file_size, get_web_page
92 from openlp.core.common.i18n import translate
93 from openlp.core.common.mixins import RegistryProperties
94 from openlp.core.common.path import Path, create_paths
95@@ -43,57 +43,50 @@
96 from openlp.core.lib import build_icon
97 from openlp.core.lib.plugin import PluginStatus
98 from openlp.core.lib.ui import critical_error_message_box
99-from openlp.core.threading import ThreadWorker, get_thread_worker, is_thread_finished, run_thread
100+from openlp.core.threading import get_thread_worker, is_thread_finished, run_thread
101 from openlp.core.ui.firsttimewizard import FirstTimePage, UiFirstTimeWizard
102+from openlp.core.ui.icons import UiIcons
103
104
105 log = logging.getLogger(__name__)
106
107
108-class ThemeScreenshotWorker(ThreadWorker):
109- """
110- This thread downloads a theme's screenshot
111- """
112- screenshot_downloaded = QtCore.pyqtSignal(str, str, str)
113-
114- def __init__(self, themes_url, title, filename, sha256, screenshot):
115- """
116- Set up the worker object
117- """
118- self.was_cancelled = False
119- self.themes_url = themes_url
120- self.title = title
121- self.filename = filename
122- self.sha256 = sha256
123- self.screenshot = screenshot
124- super().__init__()
125-
126- def start(self):
127- """
128- Run the worker
129- """
130- if self.was_cancelled:
131- return
132- try:
133- download_path = Path(gettempdir()) / 'openlp' / self.screenshot
134- is_success = download_file(self, '{host}{name}'.format(host=self.themes_url, name=self.screenshot),
135- download_path)
136- if is_success and not self.was_cancelled:
137- # Signal that the screenshot has been downloaded
138- self.screenshot_downloaded.emit(self.title, self.filename, self.sha256)
139- except: # noqa
140- log.exception('Unable to download screenshot')
141- finally:
142- self.quit.emit()
143-
144- @QtCore.pyqtSlot(bool)
145- def set_download_canceled(self, toggle):
146- """
147- Externally set if the download was canceled
148-
149- :param toggle: Set if the download was canceled or not
150- """
151- self.was_download_cancelled = toggle
152+class ThemeListWidgetItem(QtWidgets.QListWidgetItem):
153+ """
154+ Subclass a QListWidgetItem to allow dynamic loading of thumbnails from an online resource
155+ """
156+ def __init__(self, themes_url, sample_theme_data, ftw, *args, **kwargs):
157+ super().__init__(*args, **kwargs)
158+ title = sample_theme_data['title']
159+ thumbnail = sample_theme_data['thumbnail']
160+ self.file_name = sample_theme_data['file_name']
161+ self.sha256 = sample_theme_data['sha256']
162+ self.setIcon(UiIcons().picture) # Set a place holder icon whilst the thumbnails download
163+ self.setText(title)
164+ self.setToolTip(title)
165+ worker = DownloadWorker(themes_url, thumbnail)
166+ worker.download_failed.connect(self._on_download_failed)
167+ worker.download_succeeded.connect(self._on_thumbnail_downloaded)
168+ thread_name = 'thumbnail_download_{thumbnail}'.format(thumbnail=thumbnail)
169+ run_thread(worker, thread_name)
170+ ftw.thumbnail_download_threads.append(thread_name)
171+
172+ def _on_download_failed(self):
173+ """
174+ Set an icon to indicate that the thumbnail download has failed.
175+
176+ :rtype: None
177+ """
178+ self.setIcon(UiIcons().exception)
179+
180+ def _on_thumbnail_downloaded(self, thumbnail_path):
181+ """
182+ Load the thumbnail as the icon when it has downloaded.
183+
184+ :param Path thumbnail_path: Path to the file to use as a thumbnail
185+ :rtype: None
186+ """
187+ self.setIcon(build_icon(thumbnail_path))
188
189
190 class FirstTimeForm(QtWidgets.QWizard, UiFirstTimeWizard, RegistryProperties):
191@@ -110,6 +103,9 @@
192 self.web_access = True
193 self.web = ''
194 self.setup_ui(self)
195+ self.themes_list_widget.itemSelectionChanged.connect(self.on_themes_list_widget_selection_changed)
196+ self.themes_deselect_all_button.clicked.connect(self.themes_list_widget.clearSelection)
197+ self.themes_select_all_button.clicked.connect(self.themes_list_widget.selectAll)
198
199 def get_next_page_id(self):
200 """
201@@ -144,18 +140,7 @@
202 return -1
203 elif self.currentId() == FirstTimePage.NoInternet:
204 return FirstTimePage.Progress
205- elif self.currentId() == FirstTimePage.Themes:
206- self.application.set_busy_cursor()
207- while not all([is_thread_finished(thread_name) for thread_name in self.theme_screenshot_threads]):
208- time.sleep(0.1)
209- self.application.process_events()
210- # Build the screenshot icons, as this can not be done in the thread.
211- self._build_theme_screenshots()
212- self.application.set_normal_cursor()
213- self.theme_screenshot_threads = []
214- return self.get_next_page_id()
215- else:
216- return self.get_next_page_id()
217+ return self.get_next_page_id()
218
219 def exec(self):
220 """
221@@ -172,104 +157,76 @@
222 """
223 self.screens = screens
224 self.was_cancelled = False
225- self.theme_screenshot_threads = []
226+ self.thumbnail_download_threads = []
227 self.has_run_wizard = False
228
229- self.themes_list_widget.itemChanged.connect(self.on_theme_selected)
230-
231 def _download_index(self):
232 """
233 Download the configuration file and kick off the theme screenshot download threads
234 """
235 # check to see if we have web access
236 self.web_access = False
237- self.config = ConfigParser()
238+ self.config = ''
239+ web_config = None
240 user_agent = 'OpenLP/' + Registry().get('application').applicationVersion()
241 self.application.process_events()
242 try:
243- web_config = get_web_page('{host}{name}'.format(host=self.web, name='download.cfg'),
244+ web_config = get_web_page('{host}{name}'.format(host=self.web, name='download_3.0.json'),
245 headers={'User-Agent': user_agent})
246 except ConnectionError:
247 QtWidgets.QMessageBox.critical(self, translate('OpenLP.FirstTimeWizard', 'Network Error'),
248 translate('OpenLP.FirstTimeWizard', 'There was a network error attempting '
249 'to connect to retrieve initial configuration information'),
250 QtWidgets.QMessageBox.Ok)
251- web_config = False
252- if web_config:
253- try:
254- self.config.read_string(web_config)
255- self.web = self.config.get('general', 'base url')
256- self.songs_url = self.web + self.config.get('songs', 'directory') + '/'
257- self.bibles_url = self.web + self.config.get('bibles', 'directory') + '/'
258- self.themes_url = self.web + self.config.get('themes', 'directory') + '/'
259- self.web_access = True
260- except (NoSectionError, NoOptionError, MissingSectionHeaderError):
261- log.debug('A problem occurred while parsing the downloaded config file')
262- trace_error_handler(log)
263+ if web_config and self._parse_config(web_config):
264+ self.web_access = True
265 self.application.process_events()
266 self.downloading = translate('OpenLP.FirstTimeWizard', 'Downloading {name}...')
267- if self.has_run_wizard:
268- self.songs_check_box.setChecked(self.plugin_manager.get_plugin_by_name('songs').is_active())
269- self.bible_check_box.setChecked(self.plugin_manager.get_plugin_by_name('bibles').is_active())
270- self.presentation_check_box.setChecked(self.plugin_manager.get_plugin_by_name('presentations').is_active())
271- self.image_check_box.setChecked(self.plugin_manager.get_plugin_by_name('images').is_active())
272- self.media_check_box.setChecked(self.plugin_manager.get_plugin_by_name('media').is_active())
273- self.custom_check_box.setChecked(self.plugin_manager.get_plugin_by_name('custom').is_active())
274- self.song_usage_check_box.setChecked(self.plugin_manager.get_plugin_by_name('songusage').is_active())
275- self.alert_check_box.setChecked(self.plugin_manager.get_plugin_by_name('alerts').is_active())
276 self.application.set_normal_cursor()
277- # Sort out internet access for downloads
278- if self.web_access:
279- songs = self.config.get('songs', 'languages')
280- songs = songs.split(',')
281- for song in songs:
282+
283+ def _parse_config(self, web_config):
284+ try:
285+ config = json.loads(web_config)
286+ meta = config['_meta']
287+ self.web = meta['base_url']
288+ self.songs_url = self.web + meta['songs_dir'] + '/'
289+ self.bibles_url = self.web + meta['bibles_dir'] + '/'
290+ self.themes_url = self.web + meta['themes_dir'] + '/'
291+ for song in config['songs'].values():
292 self.application.process_events()
293- title = self.config.get('songs_{song}'.format(song=song), 'title')
294- filename = self.config.get('songs_{song}'.format(song=song), 'filename')
295- sha256 = self.config.get('songs_{song}'.format(song=song), 'sha256', fallback='')
296- item = QtWidgets.QListWidgetItem(title, self.songs_list_widget)
297- item.setData(QtCore.Qt.UserRole, (filename, sha256))
298+ item = QtWidgets.QListWidgetItem(song['title'], self.songs_list_widget)
299+ item.setData(QtCore.Qt.UserRole, (song['file_name'], song['sha256']))
300 item.setCheckState(QtCore.Qt.Unchecked)
301 item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable)
302- bible_languages = self.config.get('bibles', 'languages')
303- bible_languages = bible_languages.split(',')
304- for lang in bible_languages:
305+ for lang in config['bibles'].values():
306 self.application.process_events()
307- language = self.config.get('bibles_{lang}'.format(lang=lang), 'title')
308- lang_item = QtWidgets.QTreeWidgetItem(self.bibles_tree_widget, [language])
309- bibles = self.config.get('bibles_{lang}'.format(lang=lang), 'translations')
310- bibles = bibles.split(',')
311- for bible in bibles:
312+ lang_item = QtWidgets.QTreeWidgetItem(self.bibles_tree_widget, [lang['title']])
313+ for translation in lang['translations'].values():
314 self.application.process_events()
315- title = self.config.get('bible_{bible}'.format(bible=bible), 'title')
316- filename = self.config.get('bible_{bible}'.format(bible=bible), 'filename')
317- sha256 = self.config.get('bible_{bible}'.format(bible=bible), 'sha256', fallback='')
318- item = QtWidgets.QTreeWidgetItem(lang_item, [title])
319- item.setData(0, QtCore.Qt.UserRole, (filename, sha256))
320+ item = QtWidgets.QTreeWidgetItem(lang_item, [translation['title']])
321+ item.setData(0, QtCore.Qt.UserRole, (translation['file_name'], translation['sha256']))
322 item.setCheckState(0, QtCore.Qt.Unchecked)
323 item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable)
324 self.bibles_tree_widget.expandAll()
325 self.application.process_events()
326- # Download the theme screenshots
327- themes = self.config.get('themes', 'files').split(',')
328- for theme in themes:
329- title = self.config.get('theme_{theme}'.format(theme=theme), 'title')
330- filename = self.config.get('theme_{theme}'.format(theme=theme), 'filename')
331- sha256 = self.config.get('theme_{theme}'.format(theme=theme), 'sha256', fallback='')
332- screenshot = self.config.get('theme_{theme}'.format(theme=theme), 'screenshot')
333- worker = ThemeScreenshotWorker(self.themes_url, title, filename, sha256, screenshot)
334- worker.screenshot_downloaded.connect(self.on_screenshot_downloaded)
335- thread_name = 'theme_screenshot_{title}'.format(title=title)
336- run_thread(worker, thread_name)
337- self.theme_screenshot_threads.append(thread_name)
338+ for theme in config['themes'].values():
339+ ThemeListWidgetItem(self.themes_url, theme, self, self.themes_list_widget)
340 self.application.process_events()
341+ except Exception:
342+ log.exception('Unable to parse sample config file %s', web_config)
343+ critical_error_message_box(
344+ translate('OpenLP.FirstTimeWizard', 'Invalid index file'),
345+ translate('OpenLP.FirstTimeWizard', 'OpenLP was unable to read the resource index file. '
346+ 'Please try again later.'))
347+ return False
348+ return True
349
350 def set_defaults(self):
351 """
352 Set up display at start of theme edit.
353 """
354 self.restart()
355- self.web = 'http://openlp.org/files/frw/'
356+ self.web = 'https://get.openlp.org/ftw/'
357 self.cancel_button.clicked.connect(self.on_cancel_button_clicked)
358 self.no_internet_finish_button.clicked.connect(self.on_no_internet_finish_button_clicked)
359 self.no_internet_cancel_button.clicked.connect(self.on_no_internet_cancel_button_clicked)
360@@ -282,9 +239,18 @@
361 create_paths(Path(gettempdir(), 'openlp'))
362 self.theme_combo_box.clear()
363 if self.has_run_wizard:
364+ self.songs_check_box.setChecked(self.plugin_manager.get_plugin_by_name('songs').is_active())
365+ self.bible_check_box.setChecked(self.plugin_manager.get_plugin_by_name('bibles').is_active())
366+ self.presentation_check_box.setChecked(
367+ self.plugin_manager.get_plugin_by_name('presentations').is_active())
368+ self.image_check_box.setChecked(self.plugin_manager.get_plugin_by_name('images').is_active())
369+ self.media_check_box.setChecked(self.plugin_manager.get_plugin_by_name('media').is_active())
370+ self.custom_check_box.setChecked(self.plugin_manager.get_plugin_by_name('custom').is_active())
371+ self.song_usage_check_box.setChecked(self.plugin_manager.get_plugin_by_name('songusage').is_active())
372+ self.alert_check_box.setChecked(self.plugin_manager.get_plugin_by_name('alerts').is_active())
373 # Add any existing themes to list.
374- for theme in self.theme_manager.get_themes():
375- self.theme_combo_box.addItem(theme)
376+ self.theme_combo_box.insertSeparator(0)
377+ self.theme_combo_box.addItems(sorted(self.theme_manager.get_themes()))
378 default_theme = Settings().value('themes/global theme')
379 # Pre-select the current default theme.
380 index = self.theme_combo_box.findText(default_theme)
381@@ -335,49 +301,34 @@
382 Process the triggering of the cancel button.
383 """
384 self.was_cancelled = True
385- if self.theme_screenshot_threads:
386- for thread_name in self.theme_screenshot_threads:
387+ if self.thumbnail_download_threads: # TODO: Use main thread list
388+ for thread_name in self.thumbnail_download_threads:
389 worker = get_thread_worker(thread_name)
390 if worker:
391- worker.set_download_canceled(True)
392+ worker.cancel_download()
393 # Was the thread created.
394- if self.theme_screenshot_threads:
395- while any([not is_thread_finished(thread_name) for thread_name in self.theme_screenshot_threads]):
396+ if self.thumbnail_download_threads:
397+ while any([not is_thread_finished(thread_name) for thread_name in self.thumbnail_download_threads]):
398 time.sleep(0.1)
399 self.application.set_normal_cursor()
400
401- def on_screenshot_downloaded(self, title, filename, sha256):
402- """
403- Add an item to the list when a theme has been downloaded
404-
405- :param title: The title of the theme
406- :param filename: The filename of the theme
407- """
408- self.themes_list_widget.blockSignals(True)
409- item = QtWidgets.QListWidgetItem(title, self.themes_list_widget)
410- item.setData(QtCore.Qt.UserRole, (filename, sha256))
411- item.setCheckState(QtCore.Qt.Unchecked)
412- item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable)
413- self.themes_list_widget.blockSignals(False)
414-
415- def on_theme_selected(self, item):
416- """
417- Add or remove a de/selected sample theme from the theme_combo_box
418-
419- :param QtWidgets.QListWidgetItem item: The item that has been de/selected
420+ def on_themes_list_widget_selection_changed(self):
421+ """
422+ Update the `theme_combo_box` with the selected items
423+
424 :rtype: None
425 """
426- theme_name = item.text()
427- if self.theme_manager and theme_name in self.theme_manager.get_themes():
428- return True
429- if item.checkState() == QtCore.Qt.Checked:
430- self.theme_combo_box.addItem(theme_name)
431- return True
432- else:
433- index = self.theme_combo_box.findText(theme_name)
434- if index != -1:
435- self.theme_combo_box.removeItem(index)
436- return True
437+ existing_themes = []
438+ if self.theme_manager:
439+ existing_themes = self.theme_manager.get_themes()
440+ for list_index in range(self.themes_list_widget.count()):
441+ item = self.themes_list_widget.item(list_index)
442+ if item.text() not in existing_themes:
443+ cbox_index = self.theme_combo_box.findText(item.text())
444+ if item.isSelected() and cbox_index == -1:
445+ self.theme_combo_box.insertItem(0, item.text())
446+ elif not item.isSelected() and cbox_index != -1:
447+ self.theme_combo_box.removeItem(cbox_index)
448
449 def on_no_internet_finish_button_clicked(self):
450 """
451@@ -396,18 +347,6 @@
452 self.was_cancelled = True
453 self.close()
454
455- def _build_theme_screenshots(self):
456- """
457- This method builds the theme screenshots' icons for all items in the ``self.themes_list_widget``.
458- """
459- themes = self.config.get('themes', 'files')
460- themes = themes.split(',')
461- for index, theme in enumerate(themes):
462- screenshot = self.config.get('theme_{theme}'.format(theme=theme), 'screenshot')
463- item = self.themes_list_widget.item(index)
464- if item:
465- item.setIcon(build_icon(Path(gettempdir(), 'openlp', screenshot)))
466-
467 def update_progress(self, count, block_size):
468 """
469 Calculate and display the download progress. This method is called by download_file().
470@@ -456,13 +395,9 @@
471 self.max_progress += size
472 iterator += 1
473 # Loop through the themes list and increase for each selected item
474- for i in range(self.themes_list_widget.count()):
475- self.application.process_events()
476- item = self.themes_list_widget.item(i)
477- if item.checkState() == QtCore.Qt.Checked:
478- filename, sha256 = item.data(QtCore.Qt.UserRole)
479- size = get_url_file_size('{path}{name}'.format(path=self.themes_url, name=filename))
480- self.max_progress += size
481+ for item in self.themes_list_widget.selectedItems():
482+ size = get_url_file_size('{url}{file}'.format(url=self.themes_url, file=item.file_name))
483+ self.max_progress += size
484 except urllib.error.URLError:
485 trace_error_handler(log)
486 critical_error_message_box(translate('OpenLP.FirstTimeWizard', 'Download Error'),
487@@ -579,15 +514,12 @@
488 missed_files.append('Bible: {name}'.format(name=bible))
489 bibles_iterator += 1
490 # Download themes
491- for i in range(self.themes_list_widget.count()):
492- item = self.themes_list_widget.item(i)
493- if item.checkState() == QtCore.Qt.Checked:
494- theme, sha256 = item.data(QtCore.Qt.UserRole)
495- self._increment_progress_bar(self.downloading.format(name=theme), 0)
496- self.previous_size = 0
497- if not download_file(self, '{path}{name}'.format(path=self.themes_url, name=theme),
498- themes_destination_path / theme, sha256):
499- missed_files.append('Theme: {name}'.format(name=theme))
500+ for item in self.themes_list_widget.selectedItems():
501+ self._increment_progress_bar(self.downloading.format(name=item.file_name), 0)
502+ self.previous_size = 0
503+ if not download_file(self, '{url}{file}'.format(url=self.themes_url, file=item.file_name),
504+ themes_destination_path / item.file_name, item.sha256):
505+ missed_files.append('Theme: name'.format(name=item.file_name))
506 if missed_files:
507 file_list = ''
508 for entry in missed_files:
509
510=== modified file 'openlp/core/ui/firsttimewizard.py'
511--- openlp/core/ui/firsttimewizard.py 2019-02-14 15:09:09 +0000
512+++ openlp/core/ui/firsttimewizard.py 2019-02-21 21:32:43 +0000
513@@ -49,6 +49,39 @@
514 Progress = 8
515
516
517+class ThemeListWidget(QtWidgets.QListWidget):
518+ """
519+ Subclass a QListWidget so we can make it look better when it resizes.
520+ """
521+ def __init__(self, *args, **kwargs):
522+ super().__init__(*args, **kwargs)
523+ self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
524+ self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
525+ self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn)
526+ self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
527+ self.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection)
528+ self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
529+ self.setIconSize(QtCore.QSize(133, 100))
530+ self.setMovement(QtWidgets.QListView.Static)
531+ self.setFlow(QtWidgets.QListView.LeftToRight)
532+ self.setProperty("isWrapping", True)
533+ self.setResizeMode(QtWidgets.QListView.Adjust)
534+ self.setViewMode(QtWidgets.QListView.IconMode)
535+ self.setUniformItemSizes(True)
536+
537+ def resizeEvent(self, event):
538+ """
539+ Resize the grid so the list looks better when its resized/
540+
541+ :param QtGui.QResizeEvent event: Not used
542+ :return: None
543+ """
544+ nominal_width = 141 # Icon width of 133 + 4 each side
545+ max_items_per_row = self.viewport().width() // nominal_width or 1 # or 1 to avoid divide by 0 errors
546+ col_size = (self.viewport().width() - 1) / max_items_per_row
547+ self.setGridSize(QtCore.QSize(col_size, 140))
548+
549+
550 class UiFirstTimeWizard(object):
551 """
552 The UI widgets for the first time wizard.
553@@ -175,27 +208,26 @@
554 self.themes_page = QtWidgets.QWizardPage()
555 self.themes_page.setObjectName('themes_page')
556 self.themes_layout = QtWidgets.QVBoxLayout(self.themes_page)
557- self.themes_layout.setContentsMargins(20, 50, 20, 60)
558 self.themes_layout.setObjectName('themes_layout')
559- self.themes_list_widget = QtWidgets.QListWidget(self.themes_page)
560- self.themes_list_widget.setViewMode(QtWidgets.QListView.IconMode)
561- self.themes_list_widget.setMovement(QtWidgets.QListView.Static)
562- self.themes_list_widget.setFlow(QtWidgets.QListView.LeftToRight)
563- self.themes_list_widget.setSpacing(4)
564- self.themes_list_widget.setUniformItemSizes(True)
565- self.themes_list_widget.setIconSize(QtCore.QSize(133, 100))
566- self.themes_list_widget.setWrapping(False)
567- self.themes_list_widget.setObjectName('themes_list_widget')
568+ self.themes_list_widget = ThemeListWidget(self.themes_page)
569 self.themes_layout.addWidget(self.themes_list_widget)
570+ self.theme_options_layout = QtWidgets.QHBoxLayout()
571 self.default_theme_layout = QtWidgets.QHBoxLayout()
572 self.theme_label = QtWidgets.QLabel(self.themes_page)
573 self.default_theme_layout.addWidget(self.theme_label)
574 self.theme_combo_box = QtWidgets.QComboBox(self.themes_page)
575 self.theme_combo_box.setEditable(False)
576- self.theme_combo_box.setInsertPolicy(QtWidgets.QComboBox.NoInsert)
577- self.theme_combo_box.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents)
578- self.default_theme_layout.addWidget(self.theme_combo_box)
579- self.themes_layout.addLayout(self.default_theme_layout)
580+ self.default_theme_layout.addWidget(self.theme_combo_box, stretch=1)
581+ self.theme_options_layout.addLayout(self.default_theme_layout, stretch=1)
582+ self.select_buttons_layout = QtWidgets.QHBoxLayout()
583+ self.themes_select_all_button = QtWidgets.QToolButton(self.themes_page)
584+ self.themes_select_all_button.setIcon(UiIcons().select_all)
585+ self.select_buttons_layout.addWidget(self.themes_select_all_button, stretch=1, alignment=QtCore.Qt.AlignRight)
586+ self.themes_deselect_all_button = QtWidgets.QToolButton(self.themes_page)
587+ self.themes_deselect_all_button.setIcon(UiIcons().select_none)
588+ self.select_buttons_layout.addWidget(self.themes_deselect_all_button)
589+ self.theme_options_layout.addLayout(self.select_buttons_layout, stretch=1)
590+ self.themes_layout.addLayout(self.theme_options_layout)
591 first_time_wizard.setPage(FirstTimePage.Themes, self.themes_page)
592 # Progress page
593 self.progress_page = QtWidgets.QWizardPage()
594@@ -271,9 +303,12 @@
595 self.songs_page.setSubTitle(translate('OpenLP.FirstTimeWizard', 'Select and download public domain songs.'))
596 self.bibles_page.setTitle(translate('OpenLP.FirstTimeWizard', 'Sample Bibles'))
597 self.bibles_page.setSubTitle(translate('OpenLP.FirstTimeWizard', 'Select and download free Bibles.'))
598+ # Themes Page
599 self.themes_page.setTitle(translate('OpenLP.FirstTimeWizard', 'Sample Themes'))
600 self.themes_page.setSubTitle(translate('OpenLP.FirstTimeWizard', 'Select and download sample themes.'))
601- self.theme_label.setText(translate('OpenLP.FirstTimeWizard', 'Select default theme:'))
602+ self.theme_label.setText(translate('OpenLP.FirstTimeWizard', 'Default theme:'))
603+ self.themes_select_all_button.setToolTip(translate('OpenLP.FirstTimeWizard', 'Select all'))
604+ self.themes_deselect_all_button.setToolTip(translate('OpenLP.FirstTimeWizard', 'Deselect all'))
605 self.progress_page.setTitle(translate('OpenLP.FirstTimeWizard', 'Downloading and Configuring'))
606 self.progress_page.setSubTitle(translate('OpenLP.FirstTimeWizard', 'Please wait while resources are downloaded '
607 'and OpenLP is configured.'))
608
609=== modified file 'openlp/core/ui/icons.py'
610--- openlp/core/ui/icons.py 2019-02-14 15:09:09 +0000
611+++ openlp/core/ui/icons.py 2019-02-21 21:32:43 +0000
612@@ -138,6 +138,8 @@
613 'search_plus': {'icon': 'fa.search-plus'},
614 'search_ref': {'icon': 'fa.institution'},
615 'search_text': {'icon': 'op.search-text'},
616+ 'select_all': {'icon': 'fa.check-square-o'},
617+ 'select_none': {'icon': 'fa.square-o'},
618 'settings': {'icon': 'fa.cogs'},
619 'shortcuts': {'icon': 'fa.wrench'},
620 'song_usage': {'icon': 'fa.line-chart'},
621
622=== modified file 'tests/functional/openlp_core/ui/test_firsttimeform.py'
623--- tests/functional/openlp_core/ui/test_firsttimeform.py 2019-02-14 15:09:09 +0000
624+++ tests/functional/openlp_core/ui/test_firsttimeform.py 2019-02-21 21:32:43 +0000
625@@ -25,40 +25,69 @@
626 import os
627 import tempfile
628 from unittest import TestCase
629-from unittest.mock import MagicMock, call, patch
630+from unittest.mock import MagicMock, call, patch, DEFAULT
631
632 from openlp.core.common.path import Path
633 from openlp.core.common.registry import Registry
634-from openlp.core.ui.firsttimeform import FirstTimeForm
635+from openlp.core.ui.firsttimeform import FirstTimeForm, ThemeListWidgetItem
636 from tests.helpers.testmixin import TestMixin
637
638
639-FAKE_CONFIG = """
640-[general]
641-base url = http://example.com/frw/
642-[songs]
643-directory = songs
644-[bibles]
645-directory = bibles
646-[themes]
647-directory = themes
648-"""
649-
650-FAKE_BROKEN_CONFIG = """
651-[general]
652-base url = http://example.com/frw/
653-[songs]
654-directory = songs
655-[bibles]
656-directory = bibles
657-"""
658-
659-FAKE_INVALID_CONFIG = """
660-<html>
661-<head><title>This is not a config file</title></head>
662-<body>Some text</body>
663-</html>
664-"""
665+INVALID_CONFIG = """
666+{
667+ "_comments": "The most recent version should be added to https://openlp.org/files/frw/download_3.0.json",
668+ "_meta": {
669+}
670+"""
671+
672+
673+class TestThemeListWidgetItem(TestCase):
674+ """
675+ Test the :class:`ThemeListWidgetItem` class
676+ """
677+ def setUp(self):
678+ self.sample_theme_data = {'file_name': 'BlueBurst.otz', 'sha256': 'sha_256_hash',
679+ 'thumbnail': 'BlueBurst.png', 'title': 'Blue Burst'}
680+ download_worker_patcher = patch('openlp.core.ui.firsttimeform.DownloadWorker')
681+ self.addCleanup(download_worker_patcher.stop)
682+ self.mocked_download_worker = download_worker_patcher.start()
683+ run_thread_patcher = patch('openlp.core.ui.firsttimeform.run_thread')
684+ self.addCleanup(run_thread_patcher.stop)
685+ self.mocked_run_thread = run_thread_patcher.start()
686+
687+ def test_init_sample_data(self):
688+ """
689+ Test that the theme data is loaded correctly in to a ThemeListWidgetItem object when instantiated
690+ """
691+ # GIVEN: A sample theme dictanary object
692+ # WHEN: Creating an instance of `ThemeListWidgetItem`
693+ instance = ThemeListWidgetItem('url', self.sample_theme_data, MagicMock())
694+
695+ # THEN: The data should have been set correctly
696+ assert instance.file_name == 'BlueBurst.otz'
697+ assert instance.sha256 == 'sha_256_hash'
698+ assert instance.text() == 'Blue Burst'
699+ assert instance.toolTip() == 'Blue Burst'
700+ self.mocked_download_worker.assert_called_once_with('url', 'BlueBurst.png')
701+
702+ def test_init_download_worker(self):
703+ """
704+ Test that the `DownloadWorker` worker is set up correctly and that the thread is started.
705+ """
706+ # GIVEN: A sample theme dictanary object
707+ mocked_ftw = MagicMock(spec=FirstTimeForm)
708+ mocked_ftw.thumbnail_download_threads = []
709+
710+ # WHEN: Creating an instance of `ThemeListWidgetItem`
711+ instance = ThemeListWidgetItem('url', self.sample_theme_data, mocked_ftw)
712+
713+ # THEN: The `DownloadWorker` should have been set up with the appropriate data
714+ self.mocked_download_worker.assert_called_once_with('url', 'BlueBurst.png')
715+ self.mocked_download_worker.download_failed.connect.called_once_with(instance._on_download_failed())
716+ self.mocked_download_worker.download_succeeded.connect.called_once_with(instance._on_thumbnail_downloaded)
717+ self.mocked_run_thread.assert_called_once_with(
718+ self.mocked_download_worker(), 'thumbnail_download_BlueBurst.png')
719+ assert mocked_ftw.thumbnail_download_threads == ['thumbnail_download_BlueBurst.png']
720
721
722 class TestFirstTimeForm(TestCase, TestMixin):
723@@ -92,7 +121,7 @@
724 assert expected_screens == frw.screens, 'The screens should be correct'
725 assert frw.web_access is True, 'The default value of self.web_access should be True'
726 assert frw.was_cancelled is False, 'The default value of self.was_cancelled should be False'
727- assert [] == frw.theme_screenshot_threads, 'The list of threads should be empty'
728+ assert [] == frw.thumbnail_download_threads, 'The list of threads should be empty'
729 assert frw.has_run_wizard is False, 'has_run_wizard should be False'
730
731 def test_set_defaults(self):
732@@ -109,6 +138,7 @@
733 patch.object(frw, 'no_internet_finish_button') as mocked_no_internet_finish_btn, \
734 patch.object(frw, 'currentIdChanged') as mocked_currentIdChanged, \
735 patch.object(frw, 'theme_combo_box') as mocked_theme_combo_box, \
736+ patch.object(frw, 'songs_check_box') as mocked_songs_check_box, \
737 patch.object(Registry, 'register_function') as mocked_register_function, \
738 patch('openlp.core.ui.firsttimeform.Settings', return_value=mocked_settings), \
739 patch('openlp.core.ui.firsttimeform.gettempdir', return_value='temp') as mocked_gettempdir, \
740@@ -122,7 +152,7 @@
741
742 # THEN: The default values should have been set
743 mocked_restart.assert_called_once()
744- assert 'http://openlp.org/files/frw/' == frw.web, 'The default URL should be set'
745+ assert 'https://get.openlp.org/ftw/' == frw.web, 'The default URL should be set'
746 mocked_cancel_button.clicked.connect.assert_called_once_with(frw.on_cancel_button_clicked)
747 mocked_no_internet_finish_btn.clicked.connect.assert_called_once_with(
748 frw.on_no_internet_finish_button_clicked)
749@@ -134,6 +164,7 @@
750 mocked_create_paths.assert_called_once_with(Path('temp', 'openlp'))
751 mocked_theme_combo_box.clear.assert_called_once()
752 mocked_theme_manager.assert_not_called()
753+ mocked_songs_check_box.assert_not_called()
754
755 def test_set_defaults_rerun(self):
756 """
757@@ -150,12 +181,17 @@
758 patch.object(frw, 'no_internet_finish_button') as mocked_no_internet_finish_btn, \
759 patch.object(frw, 'currentIdChanged') as mocked_currentIdChanged, \
760 patch.object(frw, 'theme_combo_box', **{'findText.return_value': 3}) as mocked_theme_combo_box, \
761+ patch.multiple(frw, songs_check_box=DEFAULT, bible_check_box=DEFAULT, presentation_check_box=DEFAULT,
762+ image_check_box=DEFAULT, media_check_box=DEFAULT, custom_check_box=DEFAULT,
763+ song_usage_check_box=DEFAULT, alert_check_box=DEFAULT), \
764 patch.object(Registry, 'register_function') as mocked_register_function, \
765 patch('openlp.core.ui.firsttimeform.Settings', return_value=mocked_settings), \
766 patch('openlp.core.ui.firsttimeform.gettempdir', return_value='temp') as mocked_gettempdir, \
767 patch('openlp.core.ui.firsttimeform.create_paths') as mocked_create_paths, \
768 patch.object(frw.application, 'set_normal_cursor'):
769- mocked_theme_manager = MagicMock(**{'get_themes.return_value': ['a', 'b', 'c']})
770+ mocked_plugin_manager = MagicMock()
771+ mocked_theme_manager = MagicMock(**{'get_themes.return_value': ['b', 'a', 'c']})
772+ Registry().register('plugin_manager', mocked_plugin_manager)
773 Registry().register('theme_manager', mocked_theme_manager)
774
775 # WHEN: The set_defaults() method is run
776@@ -163,7 +199,7 @@
777
778 # THEN: The default values should have been set
779 mocked_restart.assert_called_once()
780- assert 'http://openlp.org/files/frw/' == frw.web, 'The default URL should be set'
781+ assert 'https://get.openlp.org/ftw/' == frw.web, 'The default URL should be set'
782 mocked_cancel_button.clicked.connect.assert_called_once_with(frw.on_cancel_button_clicked)
783 mocked_no_internet_finish_btn.clicked.connect.assert_called_once_with(
784 frw.on_no_internet_finish_button_clicked)
785@@ -173,9 +209,13 @@
786 mocked_settings.value.assert_has_calls([call('core/has run wizard'), call('themes/global theme')])
787 mocked_gettempdir.assert_called_once()
788 mocked_create_paths.assert_called_once_with(Path('temp', 'openlp'))
789- mocked_theme_manager.assert_not_called()
790+ mocked_theme_manager.get_themes.assert_called_once()
791 mocked_theme_combo_box.clear.assert_called_once()
792- mocked_theme_combo_box.addItem.assert_has_calls([call('a'), call('b'), call('c')])
793+ mocked_plugin_manager.get_plugin_by_name.assert_has_calls(
794+ [call('songs'), call('bibles'), call('presentations'), call('images'), call('media'), call('custom'),
795+ call('songusage'), call('alerts')], any_order=True)
796+ mocked_plugin_manager.get_plugin_by_name.assert_has_calls([call().is_active()] * 8, any_order=True)
797+ mocked_theme_combo_box.addItems.assert_called_once_with(['a', 'b', 'c'])
798 mocked_theme_combo_box.findText.assert_called_once_with('Default Theme')
799 mocked_theme_combo_box.setCurrentIndex(3)
800
801@@ -192,7 +232,7 @@
802 mocked_is_thread_finished.side_effect = [False, True]
803 frw = FirstTimeForm(None)
804 frw.initialize(MagicMock())
805- frw.theme_screenshot_threads = ['test_thread']
806+ frw.thumbnail_download_threads = ['test_thread']
807 with patch.object(frw.application, 'set_normal_cursor') as mocked_set_normal_cursor:
808
809 # WHEN: on_cancel_button_clicked() is called
810@@ -201,43 +241,26 @@
811 # THEN: The right things should be called in the right order
812 assert frw.was_cancelled is True, 'The was_cancelled property should have been set to True'
813 mocked_get_thread_worker.assert_called_once_with('test_thread')
814- mocked_worker.set_download_canceled.assert_called_with(True)
815+ mocked_worker.cancel_download.assert_called_once()
816 mocked_is_thread_finished.assert_called_with('test_thread')
817 assert mocked_is_thread_finished.call_count == 2, 'isRunning() should have been called twice'
818 mocked_time.sleep.assert_called_once_with(0.1)
819 mocked_set_normal_cursor.assert_called_once_with()
820
821- def test_broken_config(self):
822- """
823- Test if we can handle an config file with missing data
824- """
825- # GIVEN: A mocked get_web_page, a First Time Wizard, an expected screen object, and a mocked broken config file
826- with patch('openlp.core.ui.firsttimeform.get_web_page') as mocked_get_web_page:
827- first_time_form = FirstTimeForm(None)
828- first_time_form.initialize(MagicMock())
829- mocked_get_web_page.return_value = FAKE_BROKEN_CONFIG
830-
831- # WHEN: The First Time Wizard is downloads the config file
832- first_time_form._download_index()
833-
834- # THEN: The First Time Form should not have web access
835- assert first_time_form.web_access is False, 'There should not be web access with a broken config file'
836-
837- def test_invalid_config(self):
838- """
839- Test if we can handle an config file in invalid format
840- """
841- # GIVEN: A mocked get_web_page, a First Time Wizard, an expected screen object, and a mocked invalid config file
842- with patch('openlp.core.ui.firsttimeform.get_web_page') as mocked_get_web_page:
843- first_time_form = FirstTimeForm(None)
844- first_time_form.initialize(MagicMock())
845- mocked_get_web_page.return_value = FAKE_INVALID_CONFIG
846-
847- # WHEN: The First Time Wizard is downloads the config file
848- first_time_form._download_index()
849-
850- # THEN: The First Time Form should not have web access
851- assert first_time_form.web_access is False, 'There should not be web access with an invalid config file'
852+ @patch('openlp.core.ui.firsttimeform.critical_error_message_box')
853+ def test__parse_config_invalid_config(self, mocked_critical_error_message_box):
854+ """
855+ Test `FirstTimeForm._parse_config` when called with invalid data
856+ """
857+ # GIVEN: An instance of `FirstTimeForm`
858+ first_time_form = FirstTimeForm(None)
859+
860+ # WHEN: Calling _parse_config with a string containing invalid data
861+ result = first_time_form._parse_config(INVALID_CONFIG)
862+
863+ # THEN: _parse_data should return False and the user should have should have been informed.
864+ assert result is False
865+ mocked_critical_error_message_box.assert_called_once()
866
867 @patch('openlp.core.ui.firsttimeform.get_web_page')
868 @patch('openlp.core.ui.firsttimeform.QtWidgets.QMessageBox')
869
870=== added file 'tests/interfaces/openlp_core/ui/test_firsttimeform.py'
871--- tests/interfaces/openlp_core/ui/test_firsttimeform.py 1970-01-01 00:00:00 +0000
872+++ tests/interfaces/openlp_core/ui/test_firsttimeform.py 2019-02-21 21:32:43 +0000
873@@ -0,0 +1,88 @@
874+# -*- coding: utf-8 -*-
875+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
876+
877+###############################################################################
878+# OpenLP - Open Source Lyrics Projection #
879+# --------------------------------------------------------------------------- #
880+# Copyright (c) 2008-2019 OpenLP Developers #
881+# --------------------------------------------------------------------------- #
882+# This program is free software; you can redistribute it and/or modify it #
883+# under the terms of the GNU General Public License as published by the Free #
884+# Software Foundation; version 2 of the License. #
885+# #
886+# This program is distributed in the hope that it will be useful, but WITHOUT #
887+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
888+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
889+# more details. #
890+# #
891+# You should have received a copy of the GNU General Public License along #
892+# with this program; if not, write to the Free Software Foundation, Inc., 59 #
893+# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
894+###############################################################################
895+"""
896+Package to test the openlp.core.ui.firsttimeform package.
897+"""
898+from unittest import TestCase
899+from unittest.mock import MagicMock, call, patch
900+
901+from openlp.core.common.path import Path
902+from openlp.core.common.registry import Registry
903+from openlp.core.ui.firsttimeform import ThemeListWidgetItem
904+from openlp.core.ui.icons import UiIcons
905+from tests.helpers.testmixin import TestMixin
906+
907+
908+class TestThemeListWidgetItem(TestCase, TestMixin):
909+ def setUp(self):
910+ self.sample_theme_data = {'file_name': 'BlueBurst.otz', 'sha256': 'sha_256_hash',
911+ 'thumbnail': 'BlueBurst.png', 'title': 'Blue Burst'}
912+ Registry.create()
913+ self.registry = Registry()
914+ mocked_app = MagicMock()
915+ mocked_app.worker_threads = {}
916+ Registry().register('application', mocked_app)
917+ self.setup_application()
918+
919+ move_to_thread_patcher = patch('openlp.core.ui.firsttimeform.DownloadWorker.moveToThread')
920+ self.addCleanup(move_to_thread_patcher.stop)
921+ move_to_thread_patcher.start()
922+ set_icon_patcher = patch('openlp.core.ui.firsttimeform.ThemeListWidgetItem.setIcon')
923+ self.addCleanup(set_icon_patcher.stop)
924+ self.mocked_set_icon = set_icon_patcher.start()
925+ q_thread_patcher = patch('openlp.core.ui.firsttimeform.QtCore.QThread')
926+ self.addCleanup(q_thread_patcher.stop)
927+ q_thread_patcher.start()
928+
929+ def test_failed_download(self):
930+ """
931+ Test that icon get set to indicate a failure when `DownloadWorker` emits the download_failed signal
932+ """
933+ # GIVEN: An instance of `DownloadWorker`
934+ instance = ThemeListWidgetItem('url', self.sample_theme_data, MagicMock()) # noqa Overcome GC issue
935+ worker_threads = Registry().get('application').worker_threads
936+ worker = worker_threads['thumbnail_download_BlueBurst.png']['worker']
937+
938+ # WHEN: `DownloadWorker` emits the `download_failed` signal
939+ worker.download_failed.emit()
940+
941+ # THEN: Then the initial loading icon should have been replaced by the exception icon
942+ self.mocked_set_icon.assert_has_calls([call(UiIcons().picture), call(UiIcons().exception)])
943+
944+ @patch('openlp.core.ui.firsttimeform.build_icon')
945+ def test_successful_download(self, mocked_build_icon):
946+ """
947+ Test that the downloaded thumbnail is set as the icon when `DownloadWorker` emits the `download_succeeded`
948+ signal
949+ """
950+ # GIVEN: An instance of `DownloadWorker`
951+ instance = ThemeListWidgetItem('url', self.sample_theme_data, MagicMock()) # noqa Overcome GC issue
952+ worker_threads = Registry().get('application').worker_threads
953+ worker = worker_threads['thumbnail_download_BlueBurst.png']['worker']
954+ test_path = Path('downlaoded', 'file')
955+
956+ # WHEN: `DownloadWorker` emits the `download_succeeded` signal
957+ worker.download_succeeded.emit(test_path)
958+
959+ # THEN: An icon should have been built from the downloaded file and used to replace the loading icon
960+ mocked_build_icon.assert_called_once_with(test_path)
961+ self.mocked_set_icon.assert_has_calls([call(UiIcons().picture), call(mocked_build_icon())])