From d2a5e8a4c44a454754f1ecbe1112c54c59a293e7 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Thu, 27 Feb 2014 23:36:33 +0200 Subject: [PATCH] Refactor SongSelect search into a separate class, and start writing some tests for that class. --- openlp/plugins/songs/forms/songselectform.py | 187 ++++----------- openlp/plugins/songs/lib/songselect.py | 201 ++++++++++++++++ .../songs/test_songbeamerimport.py | 28 +-- .../openlp_plugins/songs/test_songselect.py | 224 ++++++++++++++++++ .../songs/test_songshowplusimport.py | 12 +- tests/helpers/__init__.py | 31 +++ tests/helpers/songfileimport.py | 1 + 7 files changed, 526 insertions(+), 158 deletions(-) create mode 100644 openlp/plugins/songs/lib/songselect.py create mode 100644 tests/functional/openlp_plugins/songs/test_songselect.py create mode 100644 tests/helpers/__init__.py diff --git a/openlp/plugins/songs/forms/songselectform.py b/openlp/plugins/songs/forms/songselectform.py index f9efb70bc..a340978e1 100755 --- a/openlp/plugins/songs/forms/songselectform.py +++ b/openlp/plugins/songs/forms/songselectform.py @@ -31,30 +31,15 @@ The :mod:`~openlp.plugins.songs.forms.songselectform` module contains the GUI fo """ import logging -from http.cookiejar import CookieJar -from urllib.parse import urlencode -from urllib.request import HTTPCookieProcessor, HTTPError, build_opener -from html.parser import HTMLParser from time import sleep from PyQt4 import QtCore, QtGui -from bs4 import BeautifulSoup, NavigableString -from openlp.core import Settings +from openlp.core import Settings from openlp.core.common import Registry from openlp.core.lib import translate -from openlp.plugins.songs.lib import VerseType, clean_song from openlp.plugins.songs.forms.songselectdialog import Ui_SongSelectDialog -from openlp.plugins.songs.lib.db import Author, Song -from openlp.plugins.songs.lib.xml import SongXML - -USER_AGENT = 'Mozilla/5.0 (Linux; U; Android 4.0.3; en-us; GT-I9000 ' \ - 'Build/IML74K) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 ' \ - 'Mobile Safari/534.30' -BASE_URL = 'https://mobile.songselect.com' -LOGIN_URL = BASE_URL + '/account/login' -LOGOUT_URL = BASE_URL + '/account/logout' -SEARCH_URL = BASE_URL + '/search/results' +from openlp.plugins.songs.lib.songselect import SongSelectImport log = logging.getLogger(__name__) @@ -65,53 +50,36 @@ class SearchWorker(QtCore.QObject): """ show_info = QtCore.pyqtSignal(str, str) found_song = QtCore.pyqtSignal(dict) - finished = QtCore.pyqtSignal(list) + finished = QtCore.pyqtSignal() quit = QtCore.pyqtSignal() - def __init__(self, opener, params): + def __init__(self, importer, search_text): super().__init__() - self.opener = opener - self.params = params - self.html_parser = HTMLParser() - - def _search_and_parse_results(self, params): - params = urlencode(params) - results_page = BeautifulSoup(self.opener.open(SEARCH_URL + '?' + params).read(), 'lxml') - search_results = results_page.find_all('li', 'result pane') - songs = [] - for result in search_results: - song = { - 'title': self.html_parser.unescape(result.find('h3').string), - 'authors': [self.html_parser.unescape(author.string) for author in result.find_all('li')], - 'link': BASE_URL + result.find('a')['href'] - } - self.found_song.emit(song) - songs.append(song) - return songs + self.importer = importer + self.search_text = search_text def start(self): """ Run a search and then parse the results page of the search. """ - songs = self._search_and_parse_results(self.params) - search_results = [] - self.params['page'] = 1 - total = 0 - while songs: - search_results.extend(songs) - self.params['page'] += 1 - total += len(songs) - if total >= 1000: - self.show_info.emit( - translate('SongsPlugin.SongSelectForm', 'More than 1000 results'), - translate('SongsPlugin.SongSelectForm', 'Your search has returned more than 1000 results, it has ' - 'been stopped. Please refine your search to fetch better ' - 'results.')) - break - songs = self._search_and_parse_results(self.params) - self.finished.emit(search_results) + songs = self.importer.search(self.search_text, 1000, self._found_song_callback) + if len(songs) >= 1000: + self.show_info.emit( + translate('SongsPlugin.SongSelectForm', 'More than 1000 results'), + translate('SongsPlugin.SongSelectForm', 'Your search has returned more than 1000 results, it has ' + 'been stopped. Please refine your search to fetch better ' + 'results.')) + self.finished.emit() self.quit.emit() + def _found_song_callback(self, song): + """ + A callback used by the paginate function to notify watching processes when it finds a song. + + :param song: The song that was found + """ + self.found_song.emit(song) + class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): """ @@ -126,10 +94,7 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): self.song_count = 0 self.song = None self.plugin = plugin - self.db_manager = db_manager - self.html_parser = HTMLParser() - self.opener = build_opener(HTTPCookieProcessor(CookieJar())) - self.opener.addheaders = [('User-Agent', USER_AGENT)] + self.song_select_importer = SongSelectImport(db_manager) self.save_password_checkbox.toggled.connect(self.on_save_password_checkbox_toggled) self.login_button.clicked.connect(self.on_login_button_clicked) self.search_button.clicked.connect(self.on_search_button_clicked) @@ -182,7 +147,7 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): self.main_window.application.process_events() sleep(0.5) self.main_window.application.process_events() - self.opener.open(LOGOUT_URL) + self.song_select_importer.logout() self.main_window.application.process_events() progress_dialog.setValue(2) return QtGui.QDialog.done(self, r) @@ -194,6 +159,14 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): main_window = property(_get_main_window) + def _update_login_progress(self): + self.login_progress_bar.setValue(self.login_progress_bar.value() + 1) + self.main_window.application.process_events() + + def _update_song_progress(self): + self.song_progress_bar.setValue(self.song_progress_bar.value() + 1) + self.main_window.application.process_events() + def _view_song(self, current_item): if not current_item: return @@ -217,40 +190,17 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): song = {} for key, value in current_item.items(): song[key] = value - self.song_progress_bar.setValue(1) + self.song_progress_bar.setValue(0) self.main_window.application.process_events() - song_page = BeautifulSoup(self.opener.open(song['link']).read(), 'lxml') - self.song_progress_bar.setValue(2) - self.main_window.application.process_events() - try: - lyrics_page = BeautifulSoup(self.opener.open(song['link'] + '/lyrics').read(), 'lxml') - except HTTPError: - lyrics_page = None - self.song_progress_bar.setValue(3) - self.main_window.application.process_events() - song['copyright'] = '/'.join([li.string for li in song_page.find('ul', 'copyright').find_all('li')]) - song['copyright'] = self.html_parser.unescape(song['copyright']) - song['ccli_number'] = song_page.find('ul', 'info').find('li').string.split(':')[1].strip() - song['verses'] = [] - if lyrics_page: - verses = lyrics_page.find('section', 'lyrics').find_all('p') - verse_labels = lyrics_page.find('section', 'lyrics').find_all('h3') - for counter in range(len(verses)): - verse = {'label': verse_labels[counter].string, 'lyrics': ''} - for v in verses[counter].contents: - if isinstance(v, NavigableString): - verse['lyrics'] = verse['lyrics'] + v.string - else: - verse['lyrics'] += '\n' - verse['lyrics'] = verse['lyrics'].strip(' \n\r\t') - song['verses'].append(self.html_parser.unescape(verse)) + # Get the full song + self.song_select_importer.get_song(song, self._update_song_progress) + # Update the UI self.title_edit.setText(song['title']) self.copyright_edit.setText(song['copyright']) self.ccli_edit.setText(song['ccli_number']) for author in song['authors']: - QtGui.QListWidgetItem(self.html_parser.unescape(author), self.author_list_widget) + QtGui.QListWidgetItem(author, self.author_list_widget) for counter, verse in enumerate(song['verses']): - log.debug('Verse type: %s', verse['label']) self.lyrics_table_widget.setRowCount(self.lyrics_table_widget.rowCount() + 1) item = QtGui.QTableWidgetItem(verse['lyrics']) item.setData(QtCore.Qt.UserRole, verse['label']) @@ -296,23 +246,12 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): self.save_password_checkbox.setEnabled(False) self.login_button.setEnabled(False) self.login_spacer.setVisible(False) + self.login_progress_bar.setValue(0) self.login_progress_bar.setVisible(True) - self.login_progress_bar.setValue(1) self.main_window.application.process_events() - login_page = BeautifulSoup(self.opener.open(LOGIN_URL).read(), 'lxml') - self.login_progress_bar.setValue(2) - self.main_window.application.process_events() - token_input = login_page.find('input', attrs={'name': '__RequestVerificationToken'}) - data = urlencode({ - '__RequestVerificationToken': token_input['value'], - 'UserName': self.username_edit.text(), - 'Password': self.password_edit.text(), - 'RememberMe': 'false' - }) - posted_page = BeautifulSoup(self.opener.open(LOGIN_URL, data.encode('utf-8')).read(), 'lxml') - self.login_progress_bar.setValue(3) - self.main_window.application.process_events() - if posted_page.find('input', attrs={'name': '__RequestVerificationToken'}): + # Log the user in + if not self.song_select_importer.login( + self.username_edit.text(), self.password_edit.text(), self._update_login_progress): QtGui.QMessageBox.critical( self, translate('SongsPlugin.SongSelectForm', 'Error Logging In'), @@ -340,10 +279,10 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): """ self.view_button.setEnabled(False) self.search_button.setEnabled(False) - self.search_progress_bar.setVisible(True) self.search_progress_bar.setMinimum(0) self.search_progress_bar.setMaximum(0) self.search_progress_bar.setValue(0) + self.search_progress_bar.setVisible(True) self.search_results_widget.clear() self.result_count_label.setText(translate('SongsPlugin.SongSelectForm', 'Found %s song(s)') % self.song_count) self.main_window.application.process_events() @@ -353,8 +292,7 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): # Create thread and run search self.thread = QtCore.QThread() - self.worker = SearchWorker(self.opener, {'SearchTerm': self.search_combobox.currentText(), - 'allowredirect': 'false'}) + self.worker = SearchWorker(self.song_select_importer, self.search_combobox.currentText()) self.worker.moveToThread(self.thread) self.thread.started.connect(self.worker.start) self.worker.show_info.connect(self.on_search_show_info) @@ -378,16 +316,16 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): Add a song to the list when one is found. :param song: """ - log.debug('SongSelect (title = "%s"), (link = "%s")', song['title'], song['link']) self.song_count += 1 self.result_count_label.setText(translate('SongsPlugin.SongSelectForm', 'Found %s song(s)') % self.song_count) item_title = song['title'] + ' (' + ', '.join(song['authors']) + ')' song_item = QtGui.QListWidgetItem(item_title, self.search_results_widget) song_item.setData(QtCore.Qt.UserRole, song) - def on_search_finished(self, songs): + def on_search_finished(self): """ Slot which is called when the search is completed. + :param songs: """ self.main_window.application.process_events() @@ -410,6 +348,7 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): def on_search_results_widget_double_clicked(self, current_item): """ View a song from SongSelect + :param current_item: """ self._view_song(current_item) @@ -425,39 +364,7 @@ class SongSelectForm(QtGui.QDialog, Ui_SongSelectDialog): """ Import a song from SongSelect. """ - song = Song.populate( - title=self.song['title'], - copyright=self.song['copyright'], - ccli_number=self.song['ccli_number'] - ) - song_xml = SongXML() - verse_order = [] - for verse in self.song['verses']: - verse_type, verse_number = verse['label'].split(' ')[:2] - verse_type = VerseType.from_loose_input(verse_type) - verse_number = int(verse_number) - song_xml.add_verse_to_lyrics( - VerseType.tags[verse_type], - verse_number, - verse['lyrics'] - ) - verse_order.append('%s%s' % (VerseType.tags[verse_type], verse_number)) - song.verse_order = ' '.join(verse_order) - song.lyrics = song_xml.extract_xml() - clean_song(self.db_manager, song) - self.db_manager.save_object(song) - song.authors = [] - for author_name in self.song['authors']: - #author_name = unicode(author_name) - author = self.db_manager.get_object_filtered(Author, Author.display_name == author_name) - if not author: - author = Author.populate( - first_name=author_name.rsplit(' ', 1)[0], - last_name=author_name.rsplit(' ', 1)[1], - display_name=author_name - ) - song.authors.append(author) - self.db_manager.save_object(song) + self.song_select_importer.save_song(self.song) question_dialog = QtGui.QMessageBox() question_dialog.setWindowTitle(translate('SongsPlugin.SongSelectForm', 'Song Imported')) question_dialog.setText(translate('SongsPlugin.SongSelectForm', 'Your song has been imported, would you like ' diff --git a/openlp/plugins/songs/lib/songselect.py b/openlp/plugins/songs/lib/songselect.py new file mode 100644 index 000000000..5d9840bcb --- /dev/null +++ b/openlp/plugins/songs/lib/songselect.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2014 Raoul Snyman # +# Portions copyright (c) 2008-2014 Tim Bentley, Gerald Britton, Jonathan # +# Corwin, Samuel Findlay, Michael Gorven, Scott Guerrieri, Matthias Hub, # +# Meinert Jordan, Armin Köhler, Erik Lundin, Edwin Lunando, Brian T. Meyer. # +# Joshua Miller, Stevan Pettit, Andreas Preikschat, Mattias Põldaru, # +# Christian Richter, Philip Ridout, Simon Scudder, Jeffrey Smith, # +# Maikel Stuivenberg, Martin Thompson, Jon Tibble, Dave Warnock, # +# Frode Woldsund, Martin Zibricky, Patrick Zimmermann # +# --------------------------------------------------------------------------- # +# This program is free software; you can redistribute it and/or modify it # +# under the terms of the GNU General Public License as published by the Free # +# Software Foundation; version 2 of the License. # +# # +# This program is distributed in the hope that it will be useful, but WITHOUT # +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for # +# more details. # +# # +# You should have received a copy of the GNU General Public License along # +# with this program; if not, write to the Free Software Foundation, Inc., 59 # +# Temple Place, Suite 330, Boston, MA 02111-1307 USA # +############################################################################### +""" +The :mod:`~openlp.plugins.songs.lib.songselect` module contains the SongSelect importer itself. +""" +import logging +from http.cookiejar import CookieJar +from urllib.parse import urlencode +from urllib.request import HTTPCookieProcessor, HTTPError, build_opener +from html.parser import HTMLParser + +from bs4 import BeautifulSoup, NavigableString + +from openlp.plugins.songs.lib import Song, VerseType, clean_song, Author +from openlp.plugins.songs.lib.xml import SongXML + +USER_AGENT = 'Mozilla/5.0 (Linux; U; Android 4.0.3; en-us; GT-I9000 ' \ + 'Build/IML74K) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 ' \ + 'Mobile Safari/534.30' +BASE_URL = 'https://mobile.songselect.com' +LOGIN_URL = BASE_URL + '/account/login' +LOGOUT_URL = BASE_URL + '/account/logout' +SEARCH_URL = BASE_URL + '/search/results' + +log = logging.getLogger(__name__) + + +class SongSelectImport(object): + """ + The :class:`~openlp.plugins.songs.lib.songselect.SongSelectImport` class contains all the code which interfaces + with CCLI's SongSelect service and downloads the songs. + """ + def __init__(self, db_manager): + """ + Set up the song select importer + + :param db_manager: The song database manager + """ + self.db_manager = db_manager + self.html_parser = HTMLParser() + self.opener = build_opener(HTTPCookieProcessor(CookieJar())) + self.opener.addheaders = [('User-Agent', USER_AGENT)] + + def login(self, username, password, callback=None): + """ + Log the user into SongSelect. This method takes a username and password, and runs ``callback()`` at various + points which can be used to give the user some form of feedback. + + :param username: SongSelect username + :param password: SongSelect password + :param callback: Method to notify of progress. + :return: True on success, False on failure. + """ + if callback: + callback() + login_page = BeautifulSoup(self.opener.open(LOGIN_URL).read(), 'lxml') + if callback: + callback() + token_input = login_page.find('input', attrs={'name': '__RequestVerificationToken'}) + data = urlencode({ + '__RequestVerificationToken': token_input['value'], + 'UserName': username, + 'Password': password, + 'RememberMe': 'false' + }) + posted_page = BeautifulSoup(self.opener.open(LOGIN_URL, data.encode('utf-8')).read(), 'lxml') + if callback: + callback() + return not posted_page.find('input', attrs={'name': '__RequestVerificationToken'}) + + def logout(self): + """ + Log the user out of SongSelect + """ + self.opener.open(LOGOUT_URL) + + def search(self, search_text, max_results, callback=None): + """ + Set up a search. + + :param search_text: The text to search for. + :param max_results: Maximum number of results to fetch. + :param callback: A method which is called when each song is found, with the song as a parameter. + :return: List of songs + """ + params = {'allowredirect': 'false', 'SearchTerm': search_text} + current_page = 1 + songs = [] + while True: + if current_page > 1: + params['page'] = current_page + results_page = BeautifulSoup(self.opener.open(SEARCH_URL + '?' + urlencode(params)).read(), 'lxml') + search_results = results_page.find_all('li', 'result pane') + if not search_results: + break + for result in search_results: + song = { + 'title': self.html_parser.unescape(result.find('h3').string), + 'authors': [self.html_parser.unescape(author.string) for author in result.find_all('li')], + 'link': BASE_URL + result.find('a')['href'] + } + if callback: + callback(song) + songs.append(song) + if len(songs) >= max_results: + break + current_page += 1 + return songs + + def get_song(self, song, callback=None): + """ + Get the full song from SongSelect + + :param song: + :param callback: + """ + if callback: + callback() + song_page = BeautifulSoup(self.opener.open(song['link']).read(), 'lxml') + if callback: + callback() + try: + lyrics_page = BeautifulSoup(self.opener.open(song['link'] + '/lyrics').read(), 'lxml') + except HTTPError: + lyrics_page = None + if callback: + callback() + song['copyright'] = '/'.join([li.string for li in song_page.find('ul', 'copyright').find_all('li')]) + song['copyright'] = self.html_parser.unescape(song['copyright']) + song['ccli_number'] = song_page.find('ul', 'info').find('li').string.split(':')[1].strip() + song['verses'] = [] + if lyrics_page: + verses = lyrics_page.find('section', 'lyrics').find_all('p') + verse_labels = lyrics_page.find('section', 'lyrics').find_all('h3') + for counter in range(len(verses)): + verse = {'label': verse_labels[counter].string, 'lyrics': ''} + for v in verses[counter].contents: + if isinstance(v, NavigableString): + verse['lyrics'] = verse['lyrics'] + v.string + else: + verse['lyrics'] += '\n' + verse['lyrics'] = verse['lyrics'].strip(' \n\r\t') + song['verses'].append(self.html_parser.unescape(verse)) + for counter, author in enumerate(song['authors']): + song['authors'][counter] = self.html_parser.unescape(author) + + def save_song(self, song): + """ + Save a song to the database, using the db_manager + + :param song: + :return: + """ + db_song = Song.populate(title=song['title'], copyright=song['copyright'], ccli_number=song['ccli_number']) + song_xml = SongXML() + verse_order = [] + for verse in song['verses']: + verse_type, verse_number = verse['label'].split(' ')[:2] + verse_type = VerseType.from_loose_input(verse_type) + verse_number = int(verse_number) + song_xml.add_verse_to_lyrics(VerseType.tags[verse_type], verse_number, verse['lyrics']) + verse_order.append('%s%s' % (VerseType.tags[verse_type], verse_number)) + db_song.verse_order = ' '.join(verse_order) + db_song.lyrics = song_xml.extract_xml() + clean_song(self.db_manager, db_song) + self.db_manager.save_object(db_song) + db_song.authors = [] + for author_name in song['authors']: + author = self.db_manager.get_object_filtered(Author, Author.display_name == author_name) + if not author: + author = Author.populate(first_name=author_name.rsplit(' ', 1)[0], + last_name=author_name.rsplit(' ', 1)[1], + display_name=author_name) + db_song.authors.append(author) + self.db_manager.save_object(db_song) diff --git a/tests/functional/openlp_plugins/songs/test_songbeamerimport.py b/tests/functional/openlp_plugins/songs/test_songbeamerimport.py index dafe7c796..ba976a179 100644 --- a/tests/functional/openlp_plugins/songs/test_songbeamerimport.py +++ b/tests/functional/openlp_plugins/songs/test_songbeamerimport.py @@ -38,20 +38,20 @@ from openlp.plugins.songs.lib.songbeamerimport import SongBeamerImport TEST_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', 'resources', 'songbeamersongs')) -SONG_TEST_DATA = {'Lobsinget dem Herrn.sng': - {'title': 'GL 1 - Lobsinget dem Herrn', - 'verses': - [('1. Lobsinget dem Herrn,\no preiset Ihn gern!\n' - 'Anbetung und Lob Ihm gebühret.\n', 'v'), - ('2. Lobsingt Seiner Lieb´,\ndie einzig ihn trieb,\n' - 'zu sterben für unsere Sünden!\n', 'v'), - ('3. Lobsingt Seiner Macht!\nSein Werk ist vollbracht:\n' - 'Er sitzet zur Rechten des Vaters.\n', 'v'), - ('4. Lobsingt seiner Treu´,\ndie immerdar neu,\n' - 'bis Er uns zur Herrlichket führet!\n\n', 'v')], - 'song_book_name': 'Glaubenslieder I', - 'song_number': "1"} - } +SONG_TEST_DATA = { + 'Lobsinget dem Herrn.sng': { + 'title': 'GL 1 - Lobsinget dem Herrn', + 'verses': [ + ('1. Lobsinget dem Herrn,\no preiset Ihn gern!\nAnbetung und Lob Ihm gebühret.\n', 'v'), + ('2. Lobsingt Seiner Lieb´,\ndie einzig ihn trieb,\nzu sterben für unsere Sünden!\n', 'v'), + ('3. Lobsingt Seiner Macht!\nSein Werk ist vollbracht:\nEr sitzet zur Rechten des Vaters.\n', 'v'), + ('4. Lobsingt seiner Treu´,\ndie immerdar neu,\nbis Er uns zur Herrlichket führet!\n\n', 'v') + ], + 'song_book_name': 'Glaubenslieder I', + 'song_number': "1" + } +} + class TestSongBeamerImport(TestCase): """ diff --git a/tests/functional/openlp_plugins/songs/test_songselect.py b/tests/functional/openlp_plugins/songs/test_songselect.py new file mode 100644 index 000000000..c848d1206 --- /dev/null +++ b/tests/functional/openlp_plugins/songs/test_songselect.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2014 Raoul Snyman # +# Portions copyright (c) 2008-2014 Tim Bentley, Gerald Britton, Jonathan # +# Corwin, Samuel Findlay, Michael Gorven, Scott Guerrieri, Matthias Hub, # +# Meinert Jordan, Armin Köhler, Erik Lundin, Edwin Lunando, Brian T. Meyer. # +# Joshua Miller, Stevan Pettit, Andreas Preikschat, Mattias Põldaru, # +# Christian Richter, Philip Ridout, Simon Scudder, Jeffrey Smith, # +# Maikel Stuivenberg, Martin Thompson, Jon Tibble, Dave Warnock, # +# Frode Woldsund, Martin Zibricky, Patrick Zimmermann # +# --------------------------------------------------------------------------- # +# This program is free software; you can redistribute it and/or modify it # +# under the terms of the GNU General Public License as published by the Free # +# Software Foundation; version 2 of the License. # +# # +# This program is distributed in the hope that it will be useful, but WITHOUT # +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for # +# more details. # +# # +# You should have received a copy of the GNU General Public License along # +# with this program; if not, write to the Free Software Foundation, Inc., 59 # +# Temple Place, Suite 330, Boston, MA 02111-1307 USA # +############################################################################### +""" +This module contains tests for the CCLI SongSelect importer. +""" +from unittest import TestCase + +from openlp.plugins.songs.lib.songselect import SongSelectImport, LOGIN_URL, LOGOUT_URL, BASE_URL + +from tests.functional import MagicMock, patch + + +class TestSongSelect(TestCase): + """ + Test the :class:`~openlp.plugins.songs.lib.songselect.SongSelectImport` class + """ + def constructor_test(self): + """ + Test that constructing a basic SongSelectImport object works correctly + """ + # GIVEN: The SongSelectImporter class and a mocked out build_opener + with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener: + # WHEN: An object is instantiated + importer = SongSelectImport(None) + + # THEN: The object should have the correct properties + self.assertIsNone(importer.db_manager, 'The db_manager should be None') + self.assertIsNotNone(importer.html_parser, 'There should be a valid html_parser object') + self.assertIsNotNone(importer.opener, 'There should be a valid opener object') + self.assertEqual(1, mocked_build_opener.call_count, 'The build_opener method should have been called once') + + def login_fails_test(self): + """ + Test that when logging in to SongSelect fails, the login method returns False + """ + # GIVEN: A bunch of mocked out stuff and an importer object + with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener, \ + patch('openlp.plugins.songs.lib.songselect.BeautifulSoup') as MockedBeautifulSoup: + mocked_opener = MagicMock() + mocked_build_opener.return_value = mocked_opener + mocked_login_page = MagicMock() + mocked_login_page.find.return_value = {'value': 'blah'} + MockedBeautifulSoup.return_value = mocked_login_page + mock_callback = MagicMock() + importer = SongSelectImport(None) + + # WHEN: The login method is called after being rigged to fail + result = importer.login('username', 'password', mock_callback) + + # THEN: callback was called 3 times, open was called twice, find was called twice, and False was returned + self.assertEqual(3, mock_callback.call_count, 'callback should have been called 3 times') + self.assertEqual(2, mocked_login_page.find.call_count, 'find should have been called twice') + self.assertEqual(2, mocked_opener.open.call_count, 'opener should have been called twice') + self.assertFalse(result, 'The login method should have returned False') + + def login_succeeds_test(self): + """ + Test that when logging in to SongSelect succeeds, the login method returns True + """ + # GIVEN: A bunch of mocked out stuff and an importer object + with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener, \ + patch('openlp.plugins.songs.lib.songselect.BeautifulSoup') as MockedBeautifulSoup: + mocked_opener = MagicMock() + mocked_build_opener.return_value = mocked_opener + mocked_login_page = MagicMock() + mocked_login_page.find.side_effect = [{'value': 'blah'}, None] + MockedBeautifulSoup.return_value = mocked_login_page + mock_callback = MagicMock() + importer = SongSelectImport(None) + + # WHEN: The login method is called after being rigged to fail + result = importer.login('username', 'password', mock_callback) + + # THEN: callback was called 3 times, open was called twice, find was called twice, and True was returned + self.assertEqual(3, mock_callback.call_count, 'callback should have been called 3 times') + self.assertEqual(2, mocked_login_page.find.call_count, 'find should have been called twice') + self.assertEqual(2, mocked_opener.open.call_count, 'opener should have been called twice') + self.assertTrue(result, 'The login method should have returned True') + + def logout_test(self): + """ + Test that when the logout method is called, it logs the user out of SongSelect + """ + # GIVEN: A bunch of mocked out stuff and an importer object + with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener: + mocked_opener = MagicMock() + mocked_build_opener.return_value = mocked_opener + importer = SongSelectImport(None) + + # WHEN: The login method is called after being rigged to fail + importer.logout() + + # THEN: The opener is called once with the logout url + self.assertEqual(1, mocked_opener.open.call_count, 'opener should have been called once') + mocked_opener.open.assert_called_with(LOGOUT_URL) + + def search_returns_no_results_test(self): + """ + Test that when the search finds no results, it simply returns an empty list + """ + # GIVEN: A bunch of mocked out stuff and an importer object + with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener, patch( + 'openlp.plugins.songs.lib.songselect.BeautifulSoup') as MockedBeautifulSoup: + mocked_opener = MagicMock() + mocked_build_opener.return_value = mocked_opener + mocked_results_page = MagicMock() + mocked_results_page.find_all.return_value = [] + MockedBeautifulSoup.return_value = mocked_results_page + mock_callback = MagicMock() + importer = SongSelectImport(None) + + # WHEN: The login method is called after being rigged to fail + results = importer.search('text', 1000, mock_callback) + + # THEN: callback was never called, open was called once, find_all was called once, an empty list returned + self.assertEqual(0, mock_callback.call_count, 'callback should not have been called') + self.assertEqual(1, mocked_opener.open.call_count, 'open should have been called once') + self.assertEqual(1, mocked_results_page.find_all.call_count, 'find_all should have been called once') + mocked_results_page.find_all.assert_called_with('li', 'result pane') + self.assertEqual([], results, 'The search method should have returned an empty list') + + def search_returns_two_results_test(self): + """ + Test that when the search finds 2 results, it simply returns a list with 2 results + """ + # GIVEN: A bunch of mocked out stuff and an importer object + with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener, patch( + 'openlp.plugins.songs.lib.songselect.BeautifulSoup') as MockedBeautifulSoup: + # first search result + mocked_result1 = MagicMock() + mocked_result1.find.side_effect = [MagicMock(string='Title 1'), {'href': '/url1'}] + mocked_result1.find_all.return_value = [MagicMock(string='Author 1-1'), MagicMock(string='Author 1-2')] + # second search result + mocked_result2 = MagicMock() + mocked_result2.find.side_effect = [MagicMock(string='Title 2'), {'href': '/url2'}] + mocked_result2.find_all.return_value = [MagicMock(string='Author 2-1'), MagicMock(string='Author 2-2')] + # rest of the stuff + mocked_opener = MagicMock() + mocked_build_opener.return_value = mocked_opener + mocked_results_page = MagicMock() + mocked_results_page.find_all.side_effect = [[mocked_result1, mocked_result2], []] + MockedBeautifulSoup.return_value = mocked_results_page + mock_callback = MagicMock() + importer = SongSelectImport(None) + + # WHEN: The login method is called after being rigged to fail + results = importer.search('text', 1000, mock_callback) + + # THEN: callback was never called, open was called once, find_all was called once, an empty list returned + self.assertEqual(2, mock_callback.call_count, 'callback should have been called twice') + self.assertEqual(2, mocked_opener.open.call_count, 'open should have been called twice') + self.assertEqual(2, mocked_results_page.find_all.call_count, 'find_all should have been called twice') + mocked_results_page.find_all.assert_called_with('li', 'result pane') + expected_list = [ + {'title': 'Title 1', 'authors': ['Author 1-1', 'Author 1-2'], 'link': BASE_URL + '/url1'}, + {'title': 'Title 2', 'authors': ['Author 2-1', 'Author 2-2'], 'link': BASE_URL + '/url2'} + ] + self.assertListEqual(expected_list, results, 'The search method should have returned two songs') + + def search_reaches_max_results_test(self): + """ + Test that when the search finds MAX (2) results, it simply returns a list with those (2) + """ + # GIVEN: A bunch of mocked out stuff and an importer object + with patch('openlp.plugins.songs.lib.songselect.build_opener') as mocked_build_opener, patch( + 'openlp.plugins.songs.lib.songselect.BeautifulSoup') as MockedBeautifulSoup: + # first search result + mocked_result1 = MagicMock() + mocked_result1.find.side_effect = [MagicMock(string='Title 1'), {'href': '/url1'}] + mocked_result1.find_all.return_value = [MagicMock(string='Author 1-1'), MagicMock(string='Author 1-2')] + # second search result + mocked_result2 = MagicMock() + mocked_result2.find.side_effect = [MagicMock(string='Title 2'), {'href': '/url2'}] + mocked_result2.find_all.return_value = [MagicMock(string='Author 2-1'), MagicMock(string='Author 2-2')] + # third search result + mocked_result3 = MagicMock() + mocked_result3.find.side_effect = [MagicMock(string='Title 3'), {'href': '/url3'}] + mocked_result3.find_all.return_value = [MagicMock(string='Author 3-1'), MagicMock(string='Author 3-2')] + # rest of the stuff + mocked_opener = MagicMock() + mocked_build_opener.return_value = mocked_opener + mocked_results_page = MagicMock() + mocked_results_page.find_all.side_effect = [[mocked_result1, mocked_result2, mocked_result3], []] + MockedBeautifulSoup.return_value = mocked_results_page + mock_callback = MagicMock() + importer = SongSelectImport(None) + + # WHEN: The login method is called after being rigged to fail + results = importer.search('text', 2, mock_callback) + + # THEN: callback was never called, open was called once, find_all was called once, an empty list returned + self.assertEqual(2, mock_callback.call_count, 'callback should have been called twice') + self.assertEqual(2, mocked_opener.open.call_count, 'open should have been called twice') + self.assertEqual(2, mocked_results_page.find_all.call_count, 'find_all should have been called twice') + mocked_results_page.find_all.assert_called_with('li', 'result pane') + expected_list = [{'title': 'Title 1', 'authors': ['Author 1-1', 'Author 1-2'], 'link': BASE_URL + '/url1'}, + {'title': 'Title 2', 'authors': ['Author 2-1', 'Author 2-2'], 'link': BASE_URL + '/url2'}] + self.assertListEqual(expected_list, results, 'The search method should have returned two songs') diff --git a/tests/functional/openlp_plugins/songs/test_songshowplusimport.py b/tests/functional/openlp_plugins/songs/test_songshowplusimport.py index e105cdbe2..6d909c2a9 100644 --- a/tests/functional/openlp_plugins/songs/test_songshowplusimport.py +++ b/tests/functional/openlp_plugins/songs/test_songshowplusimport.py @@ -41,6 +41,7 @@ from tests.functional import patch, MagicMock TEST_PATH = os.path.abspath( os.path.join(os.path.dirname(__file__), '..', '..', '..', 'resources', 'songshowplussongs')) + class TestSongShowPlusFileImport(SongImportTestHelper): def __init__(self, *args, **kwargs): self.importer_class_name = 'SongShowPlusImport' @@ -48,10 +49,13 @@ class TestSongShowPlusFileImport(SongImportTestHelper): super(TestSongShowPlusFileImport, self).__init__(*args, **kwargs) def test_song_import(self): - test_import = self.file_import(os.path.join(TEST_PATH, 'Amazing Grace.sbsong'), - self.load_external_result_data(os.path.join(TEST_PATH, 'Amazing Grace.json'))) - test_import = self.file_import(os.path.join(TEST_PATH, 'Beautiful Garden Of Prayer.sbsong'), - self.load_external_result_data(os.path.join(TEST_PATH, 'Beautiful Garden Of Prayer.json'))) + """ + Test that loading a SongShow Plus file works correctly on various files + """ + self.file_import(os.path.join(TEST_PATH, 'Amazing Grace.sbsong'), + self.load_external_result_data(os.path.join(TEST_PATH, 'Amazing Grace.json'))) + self.file_import(os.path.join(TEST_PATH, 'Beautiful Garden Of Prayer.sbsong'), + self.load_external_result_data(os.path.join(TEST_PATH, 'Beautiful Garden Of Prayer.json'))) class TestSongShowPlusImport(TestCase): diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 000000000..45908d5ea --- /dev/null +++ b/tests/helpers/__init__.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2014 Raoul Snyman # +# Portions copyright (c) 2008-2014 Tim Bentley, Gerald Britton, Jonathan # +# Corwin, Samuel Findlay, Michael Gorven, Scott Guerrieri, Matthias Hub, # +# Meinert Jordan, Armin Köhler, Erik Lundin, Edwin Lunando, Brian T. Meyer. # +# Joshua Miller, Stevan Pettit, Andreas Preikschat, Mattias Põldaru, # +# Christian Richter, Philip Ridout, Simon Scudder, Jeffrey Smith, # +# Maikel Stuivenberg, Martin Thompson, Jon Tibble, Dave Warnock, # +# Frode Woldsund, Martin Zibricky, Patrick Zimmermann # +# --------------------------------------------------------------------------- # +# This program is free software; you can redistribute it and/or modify it # +# under the terms of the GNU General Public License as published by the Free # +# Software Foundation; version 2 of the License. # +# # +# This program is distributed in the hope that it will be useful, but WITHOUT # +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for # +# more details. # +# # +# You should have received a copy of the GNU General Public License along # +# with this program; if not, write to the Free Software Foundation, Inc., 59 # +# Temple Place, Suite 330, Boston, MA 02111-1307 USA # +############################################################################### +""" +The :mod:`~tests.helpers` module provides helper classes for use in the tests. +""" diff --git a/tests/helpers/songfileimport.py b/tests/helpers/songfileimport.py index bc9feae8a..af99bab6a 100644 --- a/tests/helpers/songfileimport.py +++ b/tests/helpers/songfileimport.py @@ -35,6 +35,7 @@ from unittest import TestCase from tests.functional import patch, MagicMock + class SongImportTestHelper(TestCase): """ This class is designed to be a helper class to reduce repition when testing the import of song files.