diff --git a/.bzrignore b/.bzrignore index 9ae23569f..58338dd0e 100644 --- a/.bzrignore +++ b/.bzrignore @@ -1,57 +1,48 @@ *.*~ -*.~\?~ -\#*\# -build -.cache -cover -.coverage -coverage -.directory -.vscode -dist *.dll -documentation/build/doctrees -documentation/build/html *.e4* -*eric[1-9]project -.git -env -# Git files -.gitignore -htmlcov -.idea *.kate-swp *.kdev4 -.kdev4 *.komodoproject -.komodotools -list *.log* *.nja -openlp.cfg -openlp/core/resources.py.old -OpenLP.egg-info -openlp.org 2.0.e4* -openlp.pro -openlp-test-projectordb.sqlite *.orig -output *.pyc -__pycache__ -.pylint.d -.pytest_cache *.qm *.rej -# Rejected diff's -resources/innosetup/Output -resources/windows/warnOpenLP.txt *.ropeproject -tags -output +*.~\?~ +*eric[1-9]project +.cache +.coverage +.directory +.git +.gitignore +.idea +.kdev4 +.komodotools +.pylint.d +.pytest_cache +.vscode +OpenLP.egg-info +\#*\# +__pycache__ +build +cover +coverage +dist +env htmlcov +list node_modules openlp-test-projectordb.sqlite +openlp.cfg +openlp.pro +openlp/core/resources.py.old +openlp/plugins/presentations/lib/vendor/Pyro4 +openlp/plugins/presentations/lib/vendor/serpent.py +output package-lock.json -.cache +tags test tests.kdev4 diff --git a/openlp/core/common/path.py b/openlp/core/common/path.py index 126555fd6..47517fb3b 100644 --- a/openlp/core/common/path.py +++ b/openlp/core/common/path.py @@ -78,6 +78,8 @@ def path_to_str(path=None): :return: An empty string if :param:`path` is None, else a string representation of the :param:`path` :rtype: str """ + if isinstance(path, str): + return path if not isinstance(path, Path) and path is not None: raise TypeError('parameter \'path\' must be of type Path or NoneType') if path is None: diff --git a/openlp/core/ui/media/mediacontroller.py b/openlp/core/ui/media/mediacontroller.py index 27164a06a..4938c5f39 100644 --- a/openlp/core/ui/media/mediacontroller.py +++ b/openlp/core/ui/media/mediacontroller.py @@ -104,6 +104,8 @@ class MediaController(RegistryBase, LogMixin, RegistryProperties): State().update_pre_conditions('mediacontroller', True) State().update_pre_conditions('media_live', True) else: + if hasattr(self.main_window, 'splash') and self.main_window.splash.isVisible(): + self.main_window.splash.hide() State().missing_text('media_live', translate('OpenLP.SlideController', 'VLC or pymediainfo are missing, so you are unable to play any media')) return True diff --git a/openlp/plugins/presentations/lib/libreofficeserver.py b/openlp/plugins/presentations/lib/libreofficeserver.py new file mode 100644 index 000000000..b3c0cd254 --- /dev/null +++ b/openlp/plugins/presentations/lib/libreofficeserver.py @@ -0,0 +1,431 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +########################################################################## +# OpenLP - Open Source Lyrics Projection # +# ---------------------------------------------------------------------- # +# Copyright (c) 2008-2019 OpenLP Developers # +# ---------------------------------------------------------------------- # +# 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, either version 3 of the License, or # +# (at your option) any later version. # +# # +# 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, see . # +########################################################################## +""" +This module runs a Pyro4 server using LibreOffice's version of Python + +Please Note: This intentionally uses os.path over pathlib because we don't know which version of Python is shipped with +the version of LibreOffice on the user's computer. +""" +from subprocess import Popen +import sys +import os +import logging +import time + + +if sys.platform.startswith('darwin'): + # Only make the log file on OS X when running as a server + logfile = os.path.join(str(os.getenv('HOME')), 'Library', 'Application Support', 'openlp', 'libreofficeserver.log') + print('Setting up log file: {logfile}'.format(logfile=logfile)) + logging.basicConfig(filename=logfile, level=logging.INFO) + + +# Add the current directory to sys.path so that we can load the serializers +sys.path.append(os.path.join(os.path.dirname(__file__))) +# Add the vendor directory to sys.path so that we can load Pyro4 +sys.path.append(os.path.join(os.path.dirname(__file__), 'vendor')) + +from serializers import register_classes +from Pyro4 import Daemon, expose + +try: + # Wrap these imports in a try so that we can run the tests on macOS + import uno + from com.sun.star.beans import PropertyValue + from com.sun.star.task import ErrorCodeIOException +except ImportError: + # But they need to be defined for mocking + uno = None + PropertyValue = None + ErrorCodeIOException = Exception + + +log = logging.getLogger(__name__) +register_classes() + + +class TextType(object): + """ + Type Enumeration for Types of Text to request + """ + Title = 0 + SlideText = 1 + Notes = 2 + + +class LibreOfficeException(Exception): + """ + A specific exception for LO + """ + pass + + +@expose +class LibreOfficeServer(object): + """ + A Pyro4 server which controls LibreOffice + """ + def __init__(self): + """ + Set up the server + """ + self._desktop = None + self._control = None + self._document = None + self._presentation = None + self._process = None + self._manager = None + + def _create_property(self, name, value): + """ + Create an OOo style property object which are passed into some Uno methods. + """ + log.debug('create property') + property_object = PropertyValue() + property_object.Name = name + property_object.Value = value + return property_object + + def _get_text_from_page(self, slide_no, text_type=TextType.SlideText): + """ + Return any text extracted from the presentation page. + + :param slide_no: The slide the notes are required for, starting at 1 + :param notes: A boolean. If set the method searches the notes of the slide. + :param text_type: A TextType. Enumeration of the types of supported text. + """ + text = '' + if TextType.Title <= text_type <= TextType.Notes: + pages = self._document.getDrawPages() + if 0 < slide_no <= pages.getCount(): + page = pages.getByIndex(slide_no - 1) + if text_type == TextType.Notes: + page = page.getNotesPage() + for index in range(page.getCount()): + shape = page.getByIndex(index) + shape_type = shape.getShapeType() + if shape.supportsService('com.sun.star.drawing.Text'): + # if they requested title, make sure it is the title + if text_type != TextType.Title or shape_type == 'com.sun.star.presentation.TitleTextShape': + text += shape.getString() + '\n' + return text + + def start_process(self): + """ + Initialise Impress + """ + uno_command = [ + '/Applications/LibreOffice.app/Contents/MacOS/soffice', + '--nologo', + '--norestore', + '--minimized', + '--nodefault', + '--nofirststartwizard', + '--accept=pipe,name=openlp_maclo;urp;StarOffice.ServiceManager' + ] + self._process = Popen(uno_command) + + @property + def desktop(self): + """ + Set up an UNO desktop instance + """ + if self._desktop is not None: + return self._desktop + uno_instance = None + context = uno.getComponentContext() + resolver = context.ServiceManager.createInstanceWithContext('com.sun.star.bridge.UnoUrlResolver', context) + loop = 0 + while uno_instance is None and loop < 3: + try: + uno_instance = resolver.resolve('uno:pipe,name=openlp_maclo;urp;StarOffice.ComponentContext') + except Exception: + log.exception('Unable to find running instance, retrying...') + loop += 1 + try: + self._manager = uno_instance.ServiceManager + log.debug('get UNO Desktop Openoffice - createInstanceWithContext - Desktop') + desktop = self._manager.createInstanceWithContext('com.sun.star.frame.Desktop', uno_instance) + if not desktop: + raise Exception('Failed to get UNO desktop') + self._desktop = desktop + return desktop + except Exception: + log.exception('Failed to get UNO desktop') + return None + + def shutdown(self): + """ + Shut down the server + """ + can_kill = True + if hasattr(self, '_docs'): + while self._docs: + self._docs[0].close_presentation() + docs = self.desktop.getComponents() + count = 0 + if docs.hasElements(): + list_elements = docs.createEnumeration() + while list_elements.hasMoreElements(): + doc = list_elements.nextElement() + if doc.getImplementationName() != 'com.sun.star.comp.framework.BackingComp': + count += 1 + if count > 0: + log.debug('LibreOffice not terminated as docs are still open') + can_kill = False + else: + try: + self.desktop.terminate() + log.debug('LibreOffice killed') + except Exception: + log.exception('Failed to terminate LibreOffice') + if getattr(self, '_process') and can_kill: + self._process.kill() + + def load_presentation(self, file_path, screen_number): + """ + Load a presentation + """ + self._file_path = file_path + url = uno.systemPathToFileUrl(file_path) + properties = (self._create_property('Hidden', True),) + self._document = None + loop_count = 0 + while loop_count < 3: + try: + self._document = self.desktop.loadComponentFromURL(url, '_blank', 0, properties) + except Exception: + log.exception('Failed to load presentation {url}'.format(url=url)) + if self._document: + break + time.sleep(0.5) + loop_count += 1 + if loop_count == 3: + log.error('Looped too many times') + return False + self._presentation = self._document.getPresentation() + self._presentation.Display = screen_number + self._control = None + return True + + def extract_thumbnails(self, temp_folder): + """ + Create thumbnails for the presentation + """ + thumbnails = [] + thumb_dir_url = uno.systemPathToFileUrl(temp_folder) + properties = (self._create_property('FilterName', 'impress_png_Export'),) + pages = self._document.getDrawPages() + if not pages: + return [] + if not os.path.isdir(temp_folder): + os.makedirs(temp_folder) + for index in range(pages.getCount()): + page = pages.getByIndex(index) + self._document.getCurrentController().setCurrentPage(page) + url_path = '{path}/{name}.png'.format(path=thumb_dir_url, name=str(index + 1)) + path = os.path.join(temp_folder, str(index + 1) + '.png') + try: + self._document.storeToURL(url_path, properties) + thumbnails.append(path) + except ErrorCodeIOException as exception: + log.exception('ERROR! ErrorCodeIOException {error:d}'.format(error=exception.ErrCode)) + except Exception: + log.exception('{path} - Unable to store openoffice preview'.format(path=path)) + return thumbnails + + def get_titles_and_notes(self): + """ + Extract the titles and the notes from the slides. + """ + titles = [] + notes = [] + pages = self._document.getDrawPages() + for slide_no in range(1, pages.getCount() + 1): + titles.append(self._get_text_from_page(slide_no, TextType.Title).replace('\n', ' ') + '\n') + note = self._get_text_from_page(slide_no, TextType.Notes) + if len(note) == 0: + note = ' ' + notes.append(note) + return titles, notes + + def close_presentation(self): + """ + Close presentation and clean up objects. + """ + log.debug('close Presentation LibreOffice') + if self._document: + if self._presentation: + try: + self._presentation.end() + self._presentation = None + self._document.dispose() + except Exception: + log.exception("Closing presentation failed") + self._document = None + + def is_loaded(self): + """ + Returns true if a presentation is loaded. + """ + log.debug('is loaded LibreOffice') + if self._presentation is None or self._document is None: + log.debug("is_loaded: no presentation or document") + return False + try: + if self._document.getPresentation() is None: + log.debug("getPresentation failed to find a presentation") + return False + except Exception: + log.exception("getPresentation failed to find a presentation") + return False + return True + + def is_active(self): + """ + Returns true if a presentation is active and running. + """ + log.debug('is active LibreOffice') + if not self.is_loaded(): + return False + return self._control.isRunning() if self._control else False + + def unblank_screen(self): + """ + Unblanks the screen. + """ + log.debug('unblank screen LibreOffice') + return self._control.resume() + + def blank_screen(self): + """ + Blanks the screen. + """ + log.debug('blank screen LibreOffice') + self._control.blankScreen(0) + + def is_blank(self): + """ + Returns true if screen is blank. + """ + log.debug('is blank LibreOffice') + if self._control and self._control.isRunning(): + return self._control.isPaused() + else: + return False + + def stop_presentation(self): + """ + Stop the presentation, remove from screen. + """ + log.debug('stop presentation LibreOffice') + self._presentation.end() + self._control = None + + def start_presentation(self): + """ + Start the presentation from the beginning. + """ + log.debug('start presentation LibreOffice') + if self._control is None or not self._control.isRunning(): + window = self._document.getCurrentController().getFrame().getContainerWindow() + window.setVisible(True) + self._presentation.start() + self._control = self._presentation.getController() + # start() returns before the Component is ready. Try for 15 seconds. + sleep_count = 1 + while not self._control and sleep_count < 150: + time.sleep(0.1) + sleep_count += 1 + self._control = self._presentation.getController() + window.setVisible(False) + else: + self._control.activate() + self.goto_slide(1) + + def get_slide_number(self): + """ + Return the current slide number on the screen, from 1. + """ + return self._control.getCurrentSlideIndex() + 1 + + def get_slide_count(self): + """ + Return the total number of slides. + """ + return self._document.getDrawPages().getCount() + + def goto_slide(self, slide_no): + """ + Go to a specific slide (from 1). + + :param slide_no: The slide the text is required for, starting at 1 + """ + self._control.gotoSlideIndex(slide_no - 1) + + def next_step(self): + """ + Triggers the next effect of slide on the running presentation. + """ + is_paused = self._control.isPaused() + self._control.gotoNextEffect() + time.sleep(0.1) + if not is_paused and self._control.isPaused(): + self._control.gotoPreviousEffect() + + def previous_step(self): + """ + Triggers the previous slide on the running presentation. + """ + self._control.gotoPreviousEffect() + + def get_slide_text(self, slide_no): + """ + Returns the text on the slide. + + :param slide_no: The slide the text is required for, starting at 1 + """ + return self._get_text_from_page(slide_no) + + def get_slide_notes(self, slide_no): + """ + Returns the text in the slide notes. + + :param slide_no: The slide the notes are required for, starting at 1 + """ + return self._get_text_from_page(slide_no, TextType.Notes) + + +def main(): + """ + The main function which runs the server + """ + daemon = Daemon(host='localhost', port=4310) + daemon.register(LibreOfficeServer, 'openlp.libreofficeserver') + try: + daemon.requestLoop() + finally: + daemon.close() + + +if __name__ == '__main__': + main() diff --git a/openlp/plugins/presentations/lib/maclocontroller.py b/openlp/plugins/presentations/lib/maclocontroller.py new file mode 100644 index 000000000..10ba08e5e --- /dev/null +++ b/openlp/plugins/presentations/lib/maclocontroller.py @@ -0,0 +1,266 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +########################################################################## +# OpenLP - Open Source Lyrics Projection # +# ---------------------------------------------------------------------- # +# Copyright (c) 2008-2019 OpenLP Developers # +# ---------------------------------------------------------------------- # +# 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, either version 3 of the License, or # +# (at your option) any later version. # +# # +# 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, see . # +########################################################################## + +import logging +from subprocess import Popen + +from Pyro4 import Proxy + +from openlp.core.common import delete_file, is_macosx +from openlp.core.common.applocation import AppLocation +from openlp.core.common.path import Path +from openlp.core.common.registry import Registry +from openlp.core.display.screens import ScreenList +from openlp.plugins.presentations.lib.serializers import register_classes +from openlp.plugins.presentations.lib.presentationcontroller import PresentationController, PresentationDocument + + +LIBREOFFICE_PATH = Path('/Applications/LibreOffice.app') +LIBREOFFICE_PYTHON = LIBREOFFICE_PATH / 'Contents' / 'Resources' / 'python' + +if is_macosx() and LIBREOFFICE_PATH.exists(): + macuno_available = True +else: + macuno_available = False + + +log = logging.getLogger(__name__) +register_classes() + + +class MacLOController(PresentationController): + """ + Class to control interactions with MacLO presentations on Mac OS X via Pyro4. It starts the Pyro4 nameserver, + starts the LibreOfficeServer, and then controls MacLO via Pyro4. + """ + log.info('MacLOController loaded') + + def __init__(self, plugin): + """ + Initialise the class + """ + log.debug('Initialising') + super(MacLOController, self).__init__(plugin, 'maclo', MacLODocument, 'Impress on macOS') + self.supports = ['odp'] + self.also_supports = ['ppt', 'pps', 'pptx', 'ppsx', 'pptm'] + self.server_process = None + self._client = None + self._start_server() + + def _start_server(self): + """ + Start a LibreOfficeServer + """ + libreoffice_python = Path('/Applications/LibreOffice.app/Contents/Resources/python') + libreoffice_server = AppLocation.get_directory(AppLocation.PluginsDir).joinpath('presentations', 'lib', + 'libreofficeserver.py') + if libreoffice_python.exists(): + self.server_process = Popen([str(libreoffice_python), str(libreoffice_server)]) + + @property + def client(self): + """ + Set up a Pyro4 client so that we can talk to the LibreOfficeServer + """ + if not self._client: + self._client = Proxy('PYRO:openlp.libreofficeserver@localhost:4310') + if not self._client._pyroConnection: + self._client._pyroReconnect() + return self._client + + def check_available(self): + """ + MacLO is able to run on this machine. + """ + log.debug('check_available') + return macuno_available + + def start_process(self): + """ + Loads a running version of LibreOffice in the background. It is not displayed to the user but is available to + the UNO interface when required. + """ + log.debug('Started automatically by the Pyro server') + self.client.start_process() + + def kill(self): + """ + Called at system exit to clean up any running presentations. + """ + log.debug('Kill LibreOffice') + self.client.shutdown() + self.server_process.kill() + + +class MacLODocument(PresentationDocument): + """ + Class which holds information and controls a single presentation. + """ + + def __init__(self, controller, presentation): + """ + Constructor, store information about the file and initialise. + """ + log.debug('Init Presentation LibreOffice') + super(MacLODocument, self).__init__(controller, presentation) + self.client = controller.client + + def load_presentation(self): + """ + Tell the LibreOfficeServer to start the presentation. + """ + log.debug('Load Presentation LibreOffice') + if not self.client.load_presentation(str(self.file_path), ScreenList().current.number + 1): + return False + self.create_thumbnails() + self.create_titles_and_notes() + return True + + def create_thumbnails(self): + """ + Create thumbnail images for presentation. + """ + log.debug('create thumbnails LibreOffice') + if self.check_thumbnails(): + return + temp_thumbnails = self.client.extract_thumbnails(str(self.get_temp_folder())) + for index, temp_thumb in enumerate(temp_thumbnails): + temp_thumb = Path(temp_thumb) + self.convert_thumbnail(temp_thumb, index + 1) + delete_file(temp_thumb) + + def create_titles_and_notes(self): + """ + Writes the list of titles (one per slide) to 'titles.txt' and the notes to 'slideNotes[x].txt' + in the thumbnails directory + """ + titles, notes = self.client.get_titles_and_notes() + self.save_titles_and_notes(titles, notes) + + def close_presentation(self): + """ + Close presentation and clean up objects. Triggered by new object being added to SlideController or OpenLP being + shutdown. + """ + log.debug('close Presentation LibreOffice') + self.client.close_presentation() + self.controller.remove_doc(self) + + def is_loaded(self): + """ + Returns true if a presentation is loaded. + """ + log.debug('is loaded LibreOffice') + return self.client.is_loaded() + + def is_active(self): + """ + Returns true if a presentation is active and running. + """ + log.debug('is active LibreOffice') + return self.client.is_active() + + def unblank_screen(self): + """ + Unblanks the screen. + """ + log.debug('unblank screen LibreOffice') + return self.client.unblank_screen() + + def blank_screen(self): + """ + Blanks the screen. + """ + log.debug('blank screen LibreOffice') + self.client.blank_screen() + + def is_blank(self): + """ + Returns true if screen is blank. + """ + log.debug('is blank LibreOffice') + return self.client.is_blank() + + def stop_presentation(self): + """ + Stop the presentation, remove from screen. + """ + log.debug('stop presentation LibreOffice') + self.client.stop_presentation() + + def start_presentation(self): + """ + Start the presentation from the beginning. + """ + log.debug('start presentation LibreOffice') + self.client.start_presentation() + # Make sure impress doesn't steal focus, unless we're on a single screen setup + if len(ScreenList()) > 1: + Registry().get('main_window').activateWindow() + + def get_slide_number(self): + """ + Return the current slide number on the screen, from 1. + """ + return self.client.get_slide_number() + + def get_slide_count(self): + """ + Return the total number of slides. + """ + return self.client.get_slide_count() + + def goto_slide(self, slide_no): + """ + Go to a specific slide (from 1). + + :param slide_no: The slide the text is required for, starting at 1 + """ + self.client.goto_slide(slide_no) + + def next_step(self): + """ + Triggers the next effect of slide on the running presentation. + """ + self.client.next_step() + + def previous_step(self): + """ + Triggers the previous slide on the running presentation. + """ + self.client.previous_step() + + def get_slide_text(self, slide_no): + """ + Returns the text on the slide. + + :param slide_no: The slide the text is required for, starting at 1 + """ + return self.client.get_slide_text(slide_no) + + def get_slide_notes(self, slide_no): + """ + Returns the text in the slide notes. + + :param slide_no: The slide the notes are required for, starting at 1 + """ + return self.client.get_slide_notes(slide_no) diff --git a/openlp/plugins/presentations/lib/presentationcontroller.py b/openlp/plugins/presentations/lib/presentationcontroller.py index ac25fa9a4..fa0284cea 100644 --- a/openlp/plugins/presentations/lib/presentationcontroller.py +++ b/openlp/plugins/presentations/lib/presentationcontroller.py @@ -412,7 +412,8 @@ class PresentationController(object): """ log.info('PresentationController loaded') - def __init__(self, plugin=None, name='PresentationController', document_class=PresentationDocument): + def __init__(self, plugin=None, name='PresentationController', document_class=PresentationDocument, + display_name=None): """ This is the constructor for the presentationcontroller object. This provides an easy way for descendent plugins @@ -432,6 +433,7 @@ class PresentationController(object): self.docs = [] self.plugin = plugin self.name = name + self.display_name = display_name if display_name is not None else name self.document_class = document_class self.settings_section = self.plugin.settings_section self.available = None diff --git a/openlp/plugins/presentations/lib/presentationtab.py b/openlp/plugins/presentations/lib/presentationtab.py index 6a18fb330..05951a973 100644 --- a/openlp/plugins/presentations/lib/presentationtab.py +++ b/openlp/plugins/presentations/lib/presentationtab.py @@ -127,10 +127,10 @@ class PresentationTab(SettingsTab): def set_controller_text(self, checkbox, controller): if checkbox.isEnabled(): - checkbox.setText(controller.name) + checkbox.setText(controller.display_name) else: checkbox.setText(translate('PresentationPlugin.PresentationTab', - '{name} (unavailable)').format(name=controller.name)) + '{name} (unavailable)').format(name=controller.display_name)) def load(self): """ diff --git a/openlp/plugins/presentations/lib/serializers.py b/openlp/plugins/presentations/lib/serializers.py new file mode 100644 index 000000000..6e9a69cb0 --- /dev/null +++ b/openlp/plugins/presentations/lib/serializers.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +########################################################################## +# OpenLP - Open Source Lyrics Projection # +# ---------------------------------------------------------------------- # +# Copyright (c) 2008-2019 OpenLP Developers # +# ---------------------------------------------------------------------- # +# 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, either version 3 of the License, or # +# (at your option) any later version. # +# # +# 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, see . # +########################################################################## +""" +This module contains some helpers for serializing Path objects in Pyro4 +""" +try: + from openlp.core.common.path import Path +except ImportError: + from pathlib import Path + +from Pyro4.util import SerializerBase + + +def path_class_to_dict(obj): + """ + Serialize a Path object for Pyro4 + """ + return { + '__class__': 'Path', + 'parts': obj.parts + } + + +def path_dict_to_class(classname, d): + return Path(d['parts']) + + +def register_classes(): + """ + Register the serializers + """ + SerializerBase.register_class_to_dict(Path, path_class_to_dict) + SerializerBase.register_dict_to_class('Path', path_dict_to_class) diff --git a/openlp/plugins/presentations/lib/vendor/do_not_delete.txt b/openlp/plugins/presentations/lib/vendor/do_not_delete.txt new file mode 100644 index 000000000..0c81c2425 --- /dev/null +++ b/openlp/plugins/presentations/lib/vendor/do_not_delete.txt @@ -0,0 +1,5 @@ +Vendor Directory +================ + +Do not delete this directory, it is used on Mac OS to place Pyro4 and serpent for use with Impress. + diff --git a/openlp/plugins/presentations/presentationplugin.py b/openlp/plugins/presentations/presentationplugin.py index 320437c4c..64ff07611 100644 --- a/openlp/plugins/presentations/presentationplugin.py +++ b/openlp/plugins/presentations/presentationplugin.py @@ -28,13 +28,13 @@ import os from PyQt5 import QtCore -from openlp.core.state import State from openlp.core.api.http import register_endpoint from openlp.core.common import extension_loader from openlp.core.common.i18n import translate from openlp.core.common.settings import Settings from openlp.core.lib import build_icon from openlp.core.lib.plugin import Plugin, StringContent +from openlp.core.state import State from openlp.core.ui.icons import UiIcons from openlp.plugins.presentations.endpoint import api_presentations_endpoint, presentations_endpoint from openlp.plugins.presentations.lib.presentationcontroller import PresentationController @@ -45,18 +45,20 @@ from openlp.plugins.presentations.lib.presentationtab import PresentationTab log = logging.getLogger(__name__) -__default_settings__ = {'presentations/override app': QtCore.Qt.Unchecked, - 'presentations/enable_pdf_program': QtCore.Qt.Unchecked, - 'presentations/pdf_program': None, - 'presentations/Impress': QtCore.Qt.Checked, - 'presentations/Powerpoint': QtCore.Qt.Checked, - 'presentations/Pdf': QtCore.Qt.Checked, - 'presentations/presentations files': [], - 'presentations/thumbnail_scheme': '', - 'presentations/powerpoint slide click advance': QtCore.Qt.Unchecked, - 'presentations/powerpoint control window': QtCore.Qt.Unchecked, - 'presentations/last directory': None - } +__default_settings__ = { + 'presentations/override app': QtCore.Qt.Unchecked, + 'presentations/enable_pdf_program': QtCore.Qt.Unchecked, + 'presentations/pdf_program': None, + 'presentations/maclo': QtCore.Qt.Checked, + 'presentations/Impress': QtCore.Qt.Checked, + 'presentations/Powerpoint': QtCore.Qt.Checked, + 'presentations/Pdf': QtCore.Qt.Checked, + 'presentations/presentations files': [], + 'presentations/thumbnail_scheme': '', + 'presentations/powerpoint slide click advance': QtCore.Qt.Unchecked, + 'presentations/powerpoint control window': QtCore.Qt.Unchecked, + 'presentations/last directory': None +} class PresentationPlugin(Plugin): @@ -100,7 +102,7 @@ class PresentationPlugin(Plugin): try: self.controllers[controller].start_process() except Exception: - log.warning('Failed to start controller process') + log.exception('Failed to start controller process') self.controllers[controller].available = False self.media_item.build_file_mask_string() diff --git a/scripts/check_dependencies.py b/scripts/check_dependencies.py index 19325fbb8..9ace75258 100755 --- a/scripts/check_dependencies.py +++ b/scripts/check_dependencies.py @@ -160,6 +160,8 @@ def check_module(mod, text='', indent=' '): w('OK') except ImportError: w('FAIL') + except Exception: + w('ERROR') w(os.linesep) diff --git a/tests/functional/openlp_core/common/test_path.py b/tests/functional/openlp_core/common/test_path.py index 5b983bc20..861022b7e 100644 --- a/tests/functional/openlp_core/common/test_path.py +++ b/tests/functional/openlp_core/common/test_path.py @@ -110,7 +110,18 @@ class TestPath(TestCase): # WHEN: Calling `path_to_str` with an invalid Type # THEN: A TypeError should have been raised with self.assertRaises(TypeError): - path_to_str(str()) + path_to_str(57) + + def test_path_to_str_wth_str(self): + """ + Test that `path_to_str` just returns a str when given a str + """ + # GIVEN: The `path_to_str` function + # WHEN: Calling `path_to_str` with a str + result = path_to_str('/usr/bin') + + # THEN: The string should be returned + assert result == '/usr/bin' def test_path_to_str_none(self): """ diff --git a/tests/functional/openlp_plugins/presentations/test_libreofficeserver.py b/tests/functional/openlp_plugins/presentations/test_libreofficeserver.py new file mode 100644 index 000000000..d747317d9 --- /dev/null +++ b/tests/functional/openlp_plugins/presentations/test_libreofficeserver.py @@ -0,0 +1,948 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +########################################################################## +# OpenLP - Open Source Lyrics Projection # +# ---------------------------------------------------------------------- # +# Copyright (c) 2008-2019 OpenLP Developers # +# ---------------------------------------------------------------------- # +# 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, either version 3 of the License, or # +# (at your option) any later version. # +# # +# 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, see . # +########################################################################## +""" +Functional tests to test the LibreOffice Pyro server +""" +from unittest.mock import MagicMock, patch, call + +from openlp.plugins.presentations.lib.libreofficeserver import LibreOfficeServer, TextType, main + + +def test_constructor(): + """ + Test the Constructor from the server + """ + # GIVEN: No server + # WHEN: The server object is created + server = LibreOfficeServer() + + # THEN: The server should have been set up correctly + assert server._control is None + # assert server._desktop is None + assert server._document is None + assert server._presentation is None + assert server._process is None + + +@patch('openlp.plugins.presentations.lib.libreofficeserver.Popen') +def test_start_process(MockedPopen): + """ + Test that the correct command is issued to run LibreOffice + """ + # GIVEN: A LOServer + mocked_process = MagicMock() + MockedPopen.return_value = mocked_process + server = LibreOfficeServer() + + # WHEN: The start_process() method is run + server.start_process() + + # THEN: The correct command line should run and the process should have started + MockedPopen.assert_called_with([ + '/Applications/LibreOffice.app/Contents/MacOS/soffice', + '--nologo', + '--norestore', + '--minimized', + '--nodefault', + '--nofirststartwizard', + '--accept=pipe,name=openlp_maclo;urp;StarOffice.ServiceManager' + ]) + assert server._process is mocked_process + + +@patch('openlp.plugins.presentations.lib.libreofficeserver.uno') +def test_desktop_already_has_desktop(mocked_uno): + """ + Test that setup_desktop() exits early when there's already a desktop + """ + # GIVEN: A LibreOfficeServer instance + server = LibreOfficeServer() + server._desktop = MagicMock() + + # WHEN: the desktop property is called + desktop = server.desktop + + # THEN: setup_desktop() exits early + assert desktop is server._desktop + assert server._manager is None + + +@patch('openlp.plugins.presentations.lib.libreofficeserver.uno') +def test_desktop_exception(mocked_uno): + """ + Test that setting up the desktop works correctly when an exception occurs + """ + # GIVEN: A LibreOfficeServer instance + server = LibreOfficeServer() + mocked_context = MagicMock() + mocked_resolver = MagicMock() + mocked_uno_instance = MagicMock() + MockedServiceManager = MagicMock() + mocked_uno.getComponentContext.return_value = mocked_context + mocked_context.ServiceManager.createInstanceWithContext.return_value = mocked_resolver + mocked_resolver.resolve.side_effect = [Exception, mocked_uno_instance] + mocked_uno_instance.ServiceManager = MockedServiceManager + MockedServiceManager.createInstanceWithContext.side_effect = Exception() + + # WHEN: the desktop property is called + server.desktop + + # THEN: A desktop object was created + mocked_uno.getComponentContext.assert_called_once_with() + mocked_context.ServiceManager.createInstanceWithContext.assert_called_once_with( + 'com.sun.star.bridge.UnoUrlResolver', mocked_context) + expected_calls = [ + call('uno:pipe,name=openlp_maclo;urp;StarOffice.ComponentContext'), + call('uno:pipe,name=openlp_maclo;urp;StarOffice.ComponentContext') + ] + assert mocked_resolver.resolve.call_args_list == expected_calls + MockedServiceManager.createInstanceWithContext.assert_called_once_with( + 'com.sun.star.frame.Desktop', mocked_uno_instance) + assert server._manager is MockedServiceManager + assert server._desktop is None + + +@patch('openlp.plugins.presentations.lib.libreofficeserver.uno') +def test_desktop(mocked_uno): + """ + Test that setting up the desktop works correctly + """ + # GIVEN: A LibreOfficeServer instance + server = LibreOfficeServer() + mocked_context = MagicMock() + mocked_resolver = MagicMock() + mocked_uno_instance = MagicMock() + MockedServiceManager = MagicMock() + mocked_desktop = MagicMock() + mocked_uno.getComponentContext.return_value = mocked_context + mocked_context.ServiceManager.createInstanceWithContext.return_value = mocked_resolver + mocked_resolver.resolve.side_effect = [Exception, mocked_uno_instance] + mocked_uno_instance.ServiceManager = MockedServiceManager + MockedServiceManager.createInstanceWithContext.return_value = mocked_desktop + + # WHEN: the desktop property is called + server.desktop + + # THEN: A desktop object was created + mocked_uno.getComponentContext.assert_called_once_with() + mocked_context.ServiceManager.createInstanceWithContext.assert_called_once_with( + 'com.sun.star.bridge.UnoUrlResolver', mocked_context) + expected_calls = [ + call('uno:pipe,name=openlp_maclo;urp;StarOffice.ComponentContext'), + call('uno:pipe,name=openlp_maclo;urp;StarOffice.ComponentContext') + ] + assert mocked_resolver.resolve.call_args_list == expected_calls + MockedServiceManager.createInstanceWithContext.assert_called_once_with( + 'com.sun.star.frame.Desktop', mocked_uno_instance) + assert server._manager is MockedServiceManager + assert server._desktop is mocked_desktop + + +@patch('openlp.plugins.presentations.lib.libreofficeserver.PropertyValue') +def test_create_property(MockedPropertyValue): + """ + Test that the _create_property() method works correctly + """ + # GIVEN: A server amnd property to set + server = LibreOfficeServer() + name = 'Hidden' + value = True + + # WHEN: The _create_property() method is called + prop = server._create_property(name, value) + + # THEN: The property should have the correct attributes + assert prop.Name == name + assert prop.Value == value + + +def test_get_text_from_page_slide_text(): + """ + Test that the _get_text_from_page() method gives us nothing for slide text + """ + # GIVEN: A LibreOfficeServer object and some mocked objects + text_type = TextType.SlideText + slide_no = 1 + server = LibreOfficeServer() + server._document = MagicMock() + mocked_pages = MagicMock() + mocked_page = MagicMock() + mocked_shape = MagicMock() + server._document.getDrawPages.return_value = mocked_pages + mocked_pages.getCount.return_value = 1 + mocked_pages.getByIndex.return_value = mocked_page + mocked_page.getByIndex.return_value = mocked_shape + mocked_shape.getShapeType.return_value = 'com.sun.star.presentation.TitleTextShape' + mocked_shape.supportsService.return_value = True + mocked_shape.getString.return_value = 'Page Text' + + # WHEN: _get_text_from_page() is run for slide text + text = server._get_text_from_page(slide_no, text_type) + + # THE: The text is correct + assert text == 'Page Text\n' + + +def test_get_text_from_page_title(): + """ + Test that the _get_text_from_page() method gives us the text from the titles + """ + # GIVEN: A LibreOfficeServer object and some mocked objects + text_type = TextType.Title + slide_no = 1 + server = LibreOfficeServer() + server._document = MagicMock() + mocked_pages = MagicMock() + mocked_page = MagicMock() + mocked_shape = MagicMock() + server._document.getDrawPages.return_value = mocked_pages + mocked_pages.getCount.return_value = 1 + mocked_pages.getByIndex.return_value = mocked_page + mocked_page.getByIndex.return_value = mocked_shape + mocked_shape.getShapeType.return_value = 'com.sun.star.presentation.TitleTextShape' + mocked_shape.supportsService.return_value = True + mocked_shape.getString.return_value = 'Page Title' + + # WHEN: _get_text_from_page() is run for titles + text = server._get_text_from_page(slide_no, text_type) + + # THEN: The text should be correct + assert text == 'Page Title\n' + + +def test_get_text_from_page_notes(): + """ + Test that the _get_text_from_page() method gives us the text from the notes + """ + # GIVEN: A LibreOfficeServer object and some mocked objects + text_type = TextType.Notes + slide_no = 1 + server = LibreOfficeServer() + server._document = MagicMock() + mocked_pages = MagicMock() + mocked_page = MagicMock() + mocked_notes_page = MagicMock() + mocked_shape = MagicMock() + server._document.getDrawPages.return_value = mocked_pages + mocked_pages.getCount.return_value = 1 + mocked_pages.getByIndex.return_value = mocked_page + mocked_page.getNotesPage.return_value = mocked_notes_page + mocked_notes_page.getByIndex.return_value = mocked_shape + mocked_shape.getShapeType.return_value = 'com.sun.star.presentation.TitleTextShape' + mocked_shape.supportsService.return_value = True + mocked_shape.getString.return_value = 'Page Notes' + + # WHEN: _get_text_from_page() is run for titles + text = server._get_text_from_page(slide_no, text_type) + + # THEN: The text should be correct + assert text == 'Page Notes\n' + + +def test_shutdown_other_docs(): + """ + Test the shutdown method while other documents are open in LibreOffice + """ + def close_docs(): + server._docs = [] + + # GIVEN: An up an running LibreOfficeServer + server = LibreOfficeServer() + mocked_doc = MagicMock() + mocked_desktop = MagicMock() + mocked_docs = MagicMock() + mocked_list = MagicMock() + mocked_element_doc = MagicMock() + server._docs = [mocked_doc] + server._desktop = mocked_desktop + server._process = MagicMock() + mocked_doc.close_presentation.side_effect = close_docs + mocked_desktop.getComponents.return_value = mocked_docs + mocked_docs.hasElements.return_value = True + mocked_docs.createEnumeration.return_value = mocked_list + mocked_list.hasMoreElements.side_effect = [True, False] + mocked_list.nextElement.return_value = mocked_element_doc + mocked_element_doc.getImplementationName.side_effect = [ + 'org.openlp.Nothing', + 'com.sun.star.comp.framework.BackingComp' + ] + + # WHEN: shutdown() is called + server.shutdown() + + # THEN: The right methods are called and everything works + mocked_doc.close_presentation.assert_called_once_with() + mocked_desktop.getComponents.assert_called_once_with() + mocked_docs.hasElements.assert_called_once_with() + mocked_docs.createEnumeration.assert_called_once_with() + assert mocked_list.hasMoreElements.call_count == 2 + mocked_list.nextElement.assert_called_once_with() + mocked_element_doc.getImplementationName.assert_called_once_with() + assert mocked_desktop.terminate.call_count == 0 + assert server._process.kill.call_count == 0 + + +def test_shutdown(): + """ + Test the shutdown method + """ + def close_docs(): + server._docs = [] + + # GIVEN: An up an running LibreOfficeServer + server = LibreOfficeServer() + mocked_doc = MagicMock() + mocked_desktop = MagicMock() + mocked_docs = MagicMock() + mocked_list = MagicMock() + mocked_element_doc = MagicMock() + server._docs = [mocked_doc] + server._desktop = mocked_desktop + server._process = MagicMock() + mocked_doc.close_presentation.side_effect = close_docs + mocked_desktop.getComponents.return_value = mocked_docs + mocked_docs.hasElements.return_value = True + mocked_docs.createEnumeration.return_value = mocked_list + mocked_list.hasMoreElements.side_effect = [True, False] + mocked_list.nextElement.return_value = mocked_element_doc + mocked_element_doc.getImplementationName.return_value = 'com.sun.star.comp.framework.BackingComp' + + # WHEN: shutdown() is called + server.shutdown() + + # THEN: The right methods are called and everything works + mocked_doc.close_presentation.assert_called_once_with() + mocked_desktop.getComponents.assert_called_once_with() + mocked_docs.hasElements.assert_called_once_with() + mocked_docs.createEnumeration.assert_called_once_with() + assert mocked_list.hasMoreElements.call_count == 2 + mocked_list.nextElement.assert_called_once_with() + mocked_element_doc.getImplementationName.assert_called_once_with() + mocked_desktop.terminate.assert_called_once_with() + server._process.kill.assert_called_once_with() + + +@patch('openlp.plugins.presentations.lib.libreofficeserver.uno') +def test_load_presentation_exception(mocked_uno): + """ + Test the load_presentation() method when an exception occurs + """ + # GIVEN: A LibreOfficeServer object + presentation_file = '/path/to/presentation.odp' + screen_number = 1 + server = LibreOfficeServer() + mocked_desktop = MagicMock() + mocked_uno.systemPathToFileUrl.side_effect = lambda x: x + server._desktop = mocked_desktop + mocked_desktop.loadComponentFromURL.side_effect = Exception() + + # WHEN: load_presentation() is called + with patch.object(server, '_create_property') as mocked_create_property: + mocked_create_property.side_effect = lambda x, y: {x: y} + result = server.load_presentation(presentation_file, screen_number) + + # THEN: A presentation is loaded + assert result is False + + +@patch('openlp.plugins.presentations.lib.libreofficeserver.uno') +def test_load_presentation(mocked_uno): + """ + Test the load_presentation() method + """ + # GIVEN: A LibreOfficeServer object + presentation_file = '/path/to/presentation.odp' + screen_number = 1 + server = LibreOfficeServer() + mocked_desktop = MagicMock() + mocked_document = MagicMock() + mocked_presentation = MagicMock() + mocked_uno.systemPathToFileUrl.side_effect = lambda x: x + server._desktop = mocked_desktop + mocked_desktop.loadComponentFromURL.return_value = mocked_document + mocked_document.getPresentation.return_value = mocked_presentation + + # WHEN: load_presentation() is called + with patch.object(server, '_create_property') as mocked_create_property: + mocked_create_property.side_effect = lambda x, y: {x: y} + result = server.load_presentation(presentation_file, screen_number) + + # THEN: A presentation is loaded + assert result is True + mocked_uno.systemPathToFileUrl.assert_called_once_with(presentation_file) + mocked_create_property.assert_called_once_with('Hidden', True) + mocked_desktop.loadComponentFromURL.assert_called_once_with( + presentation_file, '_blank', 0, ({'Hidden': True},)) + assert server._document is mocked_document + mocked_document.getPresentation.assert_called_once_with() + assert server._presentation is mocked_presentation + assert server._presentation.Display == screen_number + assert server._control is None + + +@patch('openlp.plugins.presentations.lib.libreofficeserver.uno') +def test_extract_thumbnails_no_pages(mocked_uno): + """ + Test the extract_thumbnails() method when there are no pages + """ + # GIVEN: A LibreOfficeServer instance + temp_folder = '/tmp' + server = LibreOfficeServer() + mocked_document = MagicMock() + server._document = mocked_document + mocked_uno.systemPathToFileUrl.side_effect = lambda x: x + mocked_document.getDrawPages.return_value = None + + # WHEN: The extract_thumbnails() method is called + with patch.object(server, '_create_property') as mocked_create_property: + mocked_create_property.side_effect = lambda x, y: {x: y} + thumbnails = server.extract_thumbnails(temp_folder) + + # THEN: Thumbnails have been extracted + mocked_uno.systemPathToFileUrl.assert_called_once_with(temp_folder) + mocked_create_property.assert_called_once_with('FilterName', 'impress_png_Export') + mocked_document.getDrawPages.assert_called_once_with() + assert thumbnails == [] + + +@patch('openlp.plugins.presentations.lib.libreofficeserver.uno') +@patch('openlp.plugins.presentations.lib.libreofficeserver.os') +def test_extract_thumbnails(mocked_os, mocked_uno): + """ + Test the extract_thumbnails() method + """ + # GIVEN: A LibreOfficeServer instance + temp_folder = '/tmp' + server = LibreOfficeServer() + mocked_document = MagicMock() + mocked_pages = MagicMock() + mocked_page_1 = MagicMock() + mocked_page_2 = MagicMock() + mocked_controller = MagicMock() + server._document = mocked_document + mocked_uno.systemPathToFileUrl.side_effect = lambda x: x + mocked_document.getDrawPages.return_value = mocked_pages + mocked_os.path.isdir.return_value = False + mocked_pages.getCount.return_value = 2 + mocked_pages.getByIndex.side_effect = [mocked_page_1, mocked_page_2] + mocked_document.getCurrentController.return_value = mocked_controller + mocked_os.path.join.side_effect = lambda *x: '/'.join(x) + + # WHEN: The extract_thumbnails() method is called + with patch.object(server, '_create_property') as mocked_create_property: + mocked_create_property.side_effect = lambda x, y: {x: y} + thumbnails = server.extract_thumbnails(temp_folder) + + # THEN: Thumbnails have been extracted + mocked_uno.systemPathToFileUrl.assert_called_once_with(temp_folder) + mocked_create_property.assert_called_once_with('FilterName', 'impress_png_Export') + mocked_document.getDrawPages.assert_called_once_with() + mocked_pages.getCount.assert_called_once_with() + assert mocked_pages.getByIndex.call_args_list == [call(0), call(1)] + assert mocked_controller.setCurrentPage.call_args_list == \ + [call(mocked_page_1), call(mocked_page_2)] + assert mocked_document.storeToURL.call_args_list == \ + [call('/tmp/1.png', ({'FilterName': 'impress_png_Export'},)), + call('/tmp/2.png', ({'FilterName': 'impress_png_Export'},))] + assert thumbnails == ['/tmp/1.png', '/tmp/2.png'] + + +def test_get_titles_and_notes(): + """ + Test the get_titles_and_notes() method + """ + # GIVEN: A LibreOfficeServer object and a bunch of mocks + server = LibreOfficeServer() + mocked_document = MagicMock() + mocked_pages = MagicMock() + server._document = mocked_document + mocked_document.getDrawPages.return_value = mocked_pages + mocked_pages.getCount.return_value = 2 + + # WHEN: get_titles_and_notes() is called + with patch.object(server, '_get_text_from_page') as mocked_get_text_from_page: + mocked_get_text_from_page.side_effect = [ + 'OpenLP on Mac OS X', + '', + '', + 'Installing is a drag-and-drop affair' + ] + titles, notes = server.get_titles_and_notes() + + # THEN: The right calls are made and the right stuff returned + mocked_document.getDrawPages.assert_called_once_with() + mocked_pages.getCount.assert_called_once_with() + assert mocked_get_text_from_page.call_count == 4 + expected_calls = [ + call(1, TextType.Title), call(1, TextType.Notes), + call(2, TextType.Title), call(2, TextType.Notes), + ] + assert mocked_get_text_from_page.call_args_list == expected_calls + assert titles == ['OpenLP on Mac OS X\n', '\n'], titles + assert notes == [' ', 'Installing is a drag-and-drop affair'], notes + + +def test_close_presentation(): + """ + Test that closing the presentation cleans things up correctly + """ + # GIVEN: A LibreOfficeServer instance and a bunch of mocks + server = LibreOfficeServer() + mocked_document = MagicMock() + mocked_presentation = MagicMock() + server._document = mocked_document + server._presentation = mocked_presentation + + # WHEN: close_presentation() is called + server.close_presentation() + + # THEN: The presentation and document should be closed + mocked_presentation.end.assert_called_once_with() + mocked_document.dispose.assert_called_once_with() + assert server._document is None + assert server._presentation is None + + +def test_is_loaded_no_objects(): + """ + Test the is_loaded() method when there's no document or presentation + """ + # GIVEN: A LibreOfficeServer instance and a bunch of mocks + server = LibreOfficeServer() + + # WHEN: The is_loaded() method is called + result = server.is_loaded() + + # THEN: The result should be false + assert result is False + + +def test_is_loaded_no_presentation(): + """ + Test the is_loaded() method when there's no presentation + """ + # GIVEN: A LibreOfficeServer instance and a bunch of mocks + server = LibreOfficeServer() + mocked_document = MagicMock() + server._document = mocked_document + server._presentation = MagicMock() + mocked_document.getPresentation.return_value = None + + # WHEN: The is_loaded() method is called + result = server.is_loaded() + + # THEN: The result should be false + assert result is False + mocked_document.getPresentation.assert_called_once_with() + + +def test_is_loaded_exception(): + """ + Test the is_loaded() method when an exception is thrown + """ + # GIVEN: A LibreOfficeServer instance and a bunch of mocks + server = LibreOfficeServer() + mocked_document = MagicMock() + server._document = mocked_document + server._presentation = MagicMock() + mocked_document.getPresentation.side_effect = Exception() + + # WHEN: The is_loaded() method is called + result = server.is_loaded() + + # THEN: The result should be false + assert result is False + mocked_document.getPresentation.assert_called_once_with() + + +def test_is_loaded(): + """ + Test the is_loaded() method + """ + # GIVEN: A LibreOfficeServer instance and a bunch of mocks + server = LibreOfficeServer() + mocked_document = MagicMock() + mocked_presentation = MagicMock() + server._document = mocked_document + server._presentation = mocked_presentation + mocked_document.getPresentation.return_value = mocked_presentation + + # WHEN: The is_loaded() method is called + result = server.is_loaded() + + # THEN: The result should be false + assert result is True + mocked_document.getPresentation.assert_called_once_with() + + +def test_is_active_not_loaded(): + """ + Test is_active() when is_loaded() returns False + """ + # GIVEN: A LibreOfficeServer instance and a bunch of mocks + server = LibreOfficeServer() + + # WHEN: is_active() is called with is_loaded() returns False + with patch.object(server, 'is_loaded') as mocked_is_loaded: + mocked_is_loaded.return_value = False + result = server.is_active() + + # THEN: It should have returned False + assert result is False + + +def test_is_active_no_control(): + """ + Test is_active() when is_loaded() returns True but there's no control + """ + # GIVEN: A LibreOfficeServer instance and a bunch of mocks + server = LibreOfficeServer() + + # WHEN: is_active() is called with is_loaded() returns False + with patch.object(server, 'is_loaded') as mocked_is_loaded: + mocked_is_loaded.return_value = True + result = server.is_active() + + # THEN: The result should be False + assert result is False + mocked_is_loaded.assert_called_once_with() + + +def test_is_active(): + """ + Test is_active() + """ + # GIVEN: A LibreOfficeServer instance and a bunch of mocks + server = LibreOfficeServer() + mocked_control = MagicMock() + server._control = mocked_control + mocked_control.isRunning.return_value = True + + # WHEN: is_active() is called with is_loaded() returns False + with patch.object(server, 'is_loaded') as mocked_is_loaded: + mocked_is_loaded.return_value = True + result = server.is_active() + + # THEN: The result should be False + assert result is True + mocked_is_loaded.assert_called_once_with() + mocked_control.isRunning.assert_called_once_with() + + +def test_unblank_screen(): + """ + Test the unblank_screen() method + """ + # GIVEN: A LibreOfficeServer instance and a bunch of mocks + server = LibreOfficeServer() + mocked_control = MagicMock() + server._control = mocked_control + + # WHEN: unblank_screen() is run + server.unblank_screen() + + # THEN: The resume method should have been called + mocked_control.resume.assert_called_once_with() + + +def test_blank_screen(): + """ + Test the blank_screen() method + """ + # GIVEN: A LibreOfficeServer instance and a bunch of mocks + server = LibreOfficeServer() + mocked_control = MagicMock() + server._control = mocked_control + + # WHEN: blank_screen() is run + server.blank_screen() + + # THEN: The resume method should have been called + mocked_control.blankScreen.assert_called_once_with(0) + + +def test_is_blank_no_control(): + """ + Test the is_blank() method when there's no control + """ + # GIVEN: A LibreOfficeServer instance and a bunch of mocks + server = LibreOfficeServer() + + # WHEN: is_blank() is called + result = server.is_blank() + + # THEN: It should have returned False + assert result is False + + +def test_is_blank_control_is_running(): + """ + Test the is_blank() method when the control is running + """ + # GIVEN: A LibreOfficeServer instance and a bunch of mocks + server = LibreOfficeServer() + mocked_control = MagicMock() + server._control = mocked_control + mocked_control.isRunning.return_value = True + mocked_control.isPaused.return_value = True + + # WHEN: is_blank() is called + result = server.is_blank() + + # THEN: It should have returned False + assert result is True + mocked_control.isRunning.assert_called_once_with() + mocked_control.isPaused.assert_called_once_with() + + +def test_stop_presentation(): + """ + Test the stop_presentation() method + """ + # GIVEN: A LibreOfficeServer instance and a mocked presentation + server = LibreOfficeServer() + mocked_presentation = MagicMock() + mocked_control = MagicMock() + server._presentation = mocked_presentation + server._control = mocked_control + + # WHEN: stop_presentation() is called + server.stop_presentation() + + # THEN: The presentation is ended and the control is removed + mocked_presentation.end.assert_called_once_with() + assert server._control is None + + +@patch('openlp.plugins.presentations.lib.libreofficeserver.time.sleep') +def test_start_presentation_no_control(mocked_sleep): + """ + Test the start_presentation() method when there's no control + """ + # GIVEN: A LibreOfficeServer instance and some mocks + server = LibreOfficeServer() + mocked_control = MagicMock() + mocked_document = MagicMock() + mocked_presentation = MagicMock() + mocked_controller = MagicMock() + mocked_frame = MagicMock() + mocked_window = MagicMock() + server._document = mocked_document + server._presentation = mocked_presentation + mocked_document.getCurrentController.return_value = mocked_controller + mocked_controller.getFrame.return_value = mocked_frame + mocked_frame.getContainerWindow.return_value = mocked_window + mocked_presentation.getController.side_effect = [None, mocked_control] + + # WHEN: start_presentation() is called + server.start_presentation() + + # THEN: The slide number should be correct + mocked_document.getCurrentController.assert_called_once_with() + mocked_controller.getFrame.assert_called_once_with() + mocked_frame.getContainerWindow.assert_called_once_with() + mocked_presentation.start.assert_called_once_with() + assert mocked_presentation.getController.call_count == 2 + mocked_sleep.assert_called_once_with(0.1) + assert mocked_window.setVisible.call_args_list == [call(True), call(False)] + assert server._control is mocked_control + + +def test_start_presentation(): + """ + Test the start_presentation() method when there's a control + """ + # GIVEN: A LibreOfficeServer instance and some mocks + server = LibreOfficeServer() + mocked_control = MagicMock() + server._control = mocked_control + + # WHEN: start_presentation() is called + with patch.object(server, 'goto_slide') as mocked_goto_slide: + server.start_presentation() + + # THEN: The control should have been activated and the first slide selected + mocked_control.activate.assert_called_once_with() + mocked_goto_slide.assert_called_once_with(1) + + +def test_get_slide_number(): + """ + Test the get_slide_number() method + """ + # GIVEN: A LibreOfficeServer instance and some mocks + server = LibreOfficeServer() + mocked_control = MagicMock() + mocked_control.getCurrentSlideIndex.return_value = 3 + server._control = mocked_control + + # WHEN: get_slide_number() is called + result = server.get_slide_number() + + # THEN: The slide number should be correct + assert result == 4 + + +def test_get_slide_count(): + """ + Test the get_slide_count() method + """ + # GIVEN: A LibreOfficeServer instance and some mocks + server = LibreOfficeServer() + mocked_document = MagicMock() + mocked_pages = MagicMock() + server._document = mocked_document + mocked_document.getDrawPages.return_value = mocked_pages + mocked_pages.getCount.return_value = 2 + + # WHEN: get_slide_count() is called + result = server.get_slide_count() + + # THEN: The slide count should be correct + assert result == 2 + + +def test_goto_slide(): + """ + Test the goto_slide() method + """ + # GIVEN: A LibreOfficeServer instance and some mocks + server = LibreOfficeServer() + mocked_control = MagicMock() + server._control = mocked_control + + # WHEN: goto_slide() is called + server.goto_slide(1) + + # THEN: The slide number should be correct + mocked_control.gotoSlideIndex.assert_called_once_with(0) + + +@patch('openlp.plugins.presentations.lib.libreofficeserver.time.sleep') +def test_next_step_when_paused(mocked_sleep): + """ + Test the next_step() method when paused + """ + # GIVEN: A LibreOfficeServer instance and a mocked control + server = LibreOfficeServer() + mocked_control = MagicMock() + server._control = mocked_control + mocked_control.isPaused.side_effect = [False, True] + + # WHEN: next_step() is called + server.next_step() + + # THEN: The correct call should be made + mocked_control.gotoNextEffect.assert_called_once_with() + mocked_sleep.assert_called_once_with(0.1) + assert mocked_control.isPaused.call_count == 2 + mocked_control.gotoPreviousEffect.assert_called_once_with() + + +@patch('openlp.plugins.presentations.lib.libreofficeserver.time.sleep') +def test_next_step(mocked_sleep): + """ + Test the next_step() method when paused + """ + # GIVEN: A LibreOfficeServer instance and a mocked control + server = LibreOfficeServer() + mocked_control = MagicMock() + server._control = mocked_control + mocked_control.isPaused.side_effect = [True, True] + + # WHEN: next_step() is called + server.next_step() + + # THEN: The correct call should be made + mocked_control.gotoNextEffect.assert_called_once_with() + mocked_sleep.assert_called_once_with(0.1) + assert mocked_control.isPaused.call_count == 1 + assert mocked_control.gotoPreviousEffect.call_count == 0 + + +def test_previous_step(): + """ + Test the previous_step() method + """ + # GIVEN: A LibreOfficeServer instance and a mocked control + server = LibreOfficeServer() + mocked_control = MagicMock() + server._control = mocked_control + + # WHEN: previous_step() is called + server.previous_step() + + # THEN: The correct call should be made + mocked_control.gotoPreviousEffect.assert_called_once_with() + + +def test_get_slide_text(): + """ + Test the get_slide_text() method + """ + # GIVEN: A LibreOfficeServer instance + server = LibreOfficeServer() + + # WHEN: get_slide_text() is called for a particular slide + with patch.object(server, '_get_text_from_page') as mocked_get_text_from_page: + mocked_get_text_from_page.return_value = 'OpenLP on Mac OS X' + result = server.get_slide_text(5) + + # THEN: The text should be returned + mocked_get_text_from_page.assert_called_once_with(5) + assert result == 'OpenLP on Mac OS X' + + +def test_get_slide_notes(): + """ + Test the get_slide_notes() method + """ + # GIVEN: A LibreOfficeServer instance + server = LibreOfficeServer() + + # WHEN: get_slide_notes() is called for a particular slide + with patch.object(server, '_get_text_from_page') as mocked_get_text_from_page: + mocked_get_text_from_page.return_value = 'Installing is a drag-and-drop affair' + result = server.get_slide_notes(3) + + # THEN: The text should be returned + mocked_get_text_from_page.assert_called_once_with(3, TextType.Notes) + assert result == 'Installing is a drag-and-drop affair' + + +@patch('openlp.plugins.presentations.lib.libreofficeserver.Daemon') +def test_main(MockedDaemon): + """ + Test the main() function + """ + # GIVEN: Mocked out Pyro objects + mocked_daemon = MagicMock() + MockedDaemon.return_value = mocked_daemon + + # WHEN: main() is run + main() + + # THEN: The correct calls are made + MockedDaemon.assert_called_once_with(host='localhost', port=4310) + mocked_daemon.register.assert_called_once_with(LibreOfficeServer, 'openlp.libreofficeserver') + mocked_daemon.requestLoop.assert_called_once_with() + mocked_daemon.close.assert_called_once_with() diff --git a/tests/functional/openlp_plugins/presentations/test_maclocontroller.py b/tests/functional/openlp_plugins/presentations/test_maclocontroller.py new file mode 100644 index 000000000..e53a0b576 --- /dev/null +++ b/tests/functional/openlp_plugins/presentations/test_maclocontroller.py @@ -0,0 +1,453 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +########################################################################## +# OpenLP - Open Source Lyrics Projection # +# ---------------------------------------------------------------------- # +# Copyright (c) 2008-2019 OpenLP Developers # +# ---------------------------------------------------------------------- # +# 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, either version 3 of the License, or # +# (at your option) any later version. # +# # +# 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, see . # +########################################################################## +""" +Functional tests to test the Mac LibreOffice class and related methods. +""" +import shutil +from tempfile import mkdtemp +from unittest import TestCase +from unittest.mock import MagicMock, patch, call + +from openlp.core.common.settings import Settings +from openlp.core.common.path import Path +from openlp.plugins.presentations.lib.maclocontroller import MacLOController, MacLODocument +from openlp.plugins.presentations.presentationplugin import __default_settings__ + +from tests.helpers.testmixin import TestMixin +from tests.utils.constants import TEST_RESOURCES_PATH + + +class TestMacLOController(TestCase, TestMixin): + """ + Test the MacLOController Class + """ + + def setUp(self): + """ + Set up the patches and mocks need for all tests. + """ + self.setup_application() + self.build_settings() + self.mock_plugin = MagicMock() + self.temp_folder = mkdtemp() + self.mock_plugin.settings_section = self.temp_folder + + def tearDown(self): + """ + Stop the patches + """ + self.destroy_settings() + shutil.rmtree(self.temp_folder) + + @patch('openlp.plugins.presentations.lib.maclocontroller.MacLOController._start_server') + def test_constructor(self, mocked_start_server): + """ + Test the Constructor from the MacLOController + """ + # GIVEN: No presentation controller + controller = None + + # WHEN: The presentation controller object is created + controller = MacLOController(plugin=self.mock_plugin) + + # THEN: The name of the presentation controller should be correct + assert controller.name == 'maclo', \ + 'The name of the presentation controller should be correct' + assert controller.display_name == 'Impress on macOS', \ + 'The display name of the presentation controller should be correct' + mocked_start_server.assert_called_once_with() + + @patch('openlp.plugins.presentations.lib.maclocontroller.MacLOController._start_server') + @patch('openlp.plugins.presentations.lib.maclocontroller.Proxy') + def test_client(self, MockedProxy, mocked_start_server): + """ + Test the client property of the Controller + """ + # GIVEN: A controller without a client and a mocked out Pyro + controller = MacLOController(plugin=self.mock_plugin) + mocked_client = MagicMock() + MockedProxy.return_value = mocked_client + mocked_client._pyroConnection = None + + # WHEN: the client property is called the first time + client = controller.client + + # THEN: a client is created + assert client == mocked_client + MockedProxy.assert_called_once_with('PYRO:openlp.libreofficeserver@localhost:4310') + mocked_client._pyroReconnect.assert_called_once_with() + + @patch('openlp.plugins.presentations.lib.maclocontroller.MacLOController._start_server') + def test_check_available(self, mocked_start_server): + """ + Test the check_available() method + """ + from openlp.plugins.presentations.lib.maclocontroller import macuno_available + + # GIVEN: A controller + controller = MacLOController(plugin=self.mock_plugin) + + # WHEN: check_available() is run + result = controller.check_available() + + # THEN: it should return false + assert result == macuno_available + + @patch('openlp.plugins.presentations.lib.maclocontroller.MacLOController._start_server') + def test_start_process(self, mocked_start_server): + """ + Test the start_process() method + """ + # GIVEN: A controller and a client + controller = MacLOController(plugin=self.mock_plugin) + controller._client = MagicMock() + + # WHEN: start_process() is called + controller.start_process() + + # THEN: The client's start_process() should have been called + controller._client.start_process.assert_called_once_with() + + @patch('openlp.plugins.presentations.lib.maclocontroller.MacLOController._start_server') + def test_kill(self, mocked_start_server): + """ + Test the kill() method + """ + # GIVEN: A controller and a client + controller = MacLOController(plugin=self.mock_plugin) + controller._client = MagicMock() + controller.server_process = MagicMock() + + # WHEN: start_process() is called + controller.kill() + + # THEN: The client's start_process() should have been called + controller._client.shutdown.assert_called_once_with() + controller.server_process.kill.assert_called_once_with() + + +class TestMacLODocument(TestCase): + """ + Test the MacLODocument Class + """ + def setUp(self): + mocked_plugin = MagicMock() + mocked_plugin.settings_section = 'presentations' + Settings().extend_default_settings(__default_settings__) + self.file_name = Path(TEST_RESOURCES_PATH) / 'presentations' / 'test.odp' + self.mocked_client = MagicMock() + with patch('openlp.plugins.presentations.lib.maclocontroller.MacLOController._start_server'): + self.controller = MacLOController(mocked_plugin) + self.controller._client = self.mocked_client + self.document = MacLODocument(self.controller, self.file_name) + + @patch('openlp.plugins.presentations.lib.maclocontroller.ScreenList') + def test_load_presentation_cannot_load(self, MockedScreenList): + """ + Test the load_presentation() method when the server can't load the presentation + """ + # GIVEN: A document and a mocked client + mocked_screen_list = MagicMock() + MockedScreenList.return_value = mocked_screen_list + mocked_screen_list.current.number = 0 + self.mocked_client.load_presentation.return_value = False + + # WHEN: load_presentation() is called + result = self.document.load_presentation() + + # THEN: Stuff should work right + self.mocked_client.load_presentation.assert_called_once_with(str(self.file_name), 1) + assert result is False + + @patch('openlp.plugins.presentations.lib.maclocontroller.ScreenList') + def test_load_presentation(self, MockedScreenList): + """ + Test the load_presentation() method + """ + # GIVEN: A document and a mocked client + mocked_screen_list = MagicMock() + MockedScreenList.return_value = mocked_screen_list + mocked_screen_list.current.number = 0 + self.mocked_client.load_presentation.return_value = True + + # WHEN: load_presentation() is called + with patch.object(self.document, 'create_thumbnails') as mocked_create_thumbnails, \ + patch.object(self.document, 'create_titles_and_notes') as mocked_create_titles_and_notes: + result = self.document.load_presentation() + + # THEN: Stuff should work right + self.mocked_client.load_presentation.assert_called_once_with(str(self.file_name), 1) + mocked_create_thumbnails.assert_called_once_with() + mocked_create_titles_and_notes.assert_called_once_with() + assert result is True + + def test_create_thumbnails_already_exist(self): + """ + Test the create_thumbnails() method when thumbnails already exist + """ + # GIVEN: thumbnails that exist and a mocked client + self.document.check_thumbnails = MagicMock(return_value=True) + + # WHEN: create_thumbnails() is called + self.document.create_thumbnails() + + # THEN: The method should exit early + assert self.mocked_client.extract_thumbnails.call_count == 0 + + @patch('openlp.plugins.presentations.lib.maclocontroller.delete_file') + def test_create_thumbnails(self, mocked_delete_file): + """ + Test the create_thumbnails() method + """ + # GIVEN: thumbnails that don't exist and a mocked client + self.document.check_thumbnails = MagicMock(return_value=False) + self.mocked_client.extract_thumbnails.return_value = ['thumb1.png', 'thumb2.png'] + + # WHEN: create_thumbnails() is called + with patch.object(self.document, 'convert_thumbnail') as mocked_convert_thumbnail, \ + patch.object(self.document, 'get_temp_folder') as mocked_get_temp_folder: + mocked_get_temp_folder.return_value = 'temp' + self.document.create_thumbnails() + + # THEN: The method should complete successfully + self.mocked_client.extract_thumbnails.assert_called_once_with('temp') + assert mocked_convert_thumbnail.call_args_list == [ + call(Path('thumb1.png'), 1), call(Path('thumb2.png'), 2)] + assert mocked_delete_file.call_args_list == [call(Path('thumb1.png')), call(Path('thumb2.png'))] + + def test_create_titles_and_notes(self): + """ + Test create_titles_and_notes() method + """ + # GIVEN: mocked client and mocked save_titles_and_notes() method + self.mocked_client.get_titles_and_notes.return_value = ('OpenLP', 'This is a note') + + # WHEN: create_titles_and_notes() is called + with patch.object(self.document, 'save_titles_and_notes') as mocked_save_titles_and_notes: + self.document.create_titles_and_notes() + + # THEN save_titles_and_notes should have been called + self.mocked_client.get_titles_and_notes.assert_called_once_with() + mocked_save_titles_and_notes.assert_called_once_with('OpenLP', 'This is a note') + + def test_close_presentation(self): + """ + Test the close_presentation() method + """ + # GIVEN: A mocked client and mocked remove_doc() method + # WHEN: close_presentation() is called + with patch.object(self.controller, 'remove_doc') as mocked_remove_doc: + self.document.close_presentation() + + # THEN: The presentation should have been closed + self.mocked_client.close_presentation.assert_called_once_with() + mocked_remove_doc.assert_called_once_with(self.document) + + def test_is_loaded(self): + """ + Test the is_loaded() method + """ + # GIVEN: A mocked client + self.mocked_client.is_loaded.return_value = True + + # WHEN: is_loaded() is called + result = self.document.is_loaded() + + # THEN: Then the result should be correct + assert result is True + + def test_is_active(self): + """ + Test the is_active() method + """ + # GIVEN: A mocked client + self.mocked_client.is_active.return_value = True + + # WHEN: is_active() is called + result = self.document.is_active() + + # THEN: Then the result should be correct + assert result is True + + def test_unblank_screen(self): + """ + Test the unblank_screen() method + """ + # GIVEN: A mocked client + self.mocked_client.unblank_screen.return_value = True + + # WHEN: unblank_screen() is called + result = self.document.unblank_screen() + + # THEN: Then the result should be correct + self.mocked_client.unblank_screen.assert_called_once_with() + assert result is True + + def test_blank_screen(self): + """ + Test the blank_screen() method + """ + # GIVEN: A mocked client + self.mocked_client.blank_screen.return_value = True + + # WHEN: blank_screen() is called + self.document.blank_screen() + + # THEN: Then the result should be correct + self.mocked_client.blank_screen.assert_called_once_with() + + def test_is_blank(self): + """ + Test the is_blank() method + """ + # GIVEN: A mocked client + self.mocked_client.is_blank.return_value = True + + # WHEN: is_blank() is called + result = self.document.is_blank() + + # THEN: Then the result should be correct + assert result is True + + def test_stop_presentation(self): + """ + Test the stop_presentation() method + """ + # GIVEN: A mocked client + self.mocked_client.stop_presentation.return_value = True + + # WHEN: stop_presentation() is called + self.document.stop_presentation() + + # THEN: Then the result should be correct + self.mocked_client.stop_presentation.assert_called_once_with() + + @patch('openlp.plugins.presentations.lib.maclocontroller.ScreenList') + @patch('openlp.plugins.presentations.lib.maclocontroller.Registry') + def test_start_presentation(self, MockedRegistry, MockedScreenList): + """ + Test the start_presentation() method + """ + # GIVEN: a mocked client, and multiple screens + mocked_screen_list = MagicMock() + mocked_screen_list.__len__.return_value = 2 + mocked_registry = MagicMock() + mocked_main_window = MagicMock() + MockedScreenList.return_value = mocked_screen_list + MockedRegistry.return_value = mocked_registry + mocked_screen_list.screen_list = [0, 1] + mocked_registry.get.return_value = mocked_main_window + + # WHEN: start_presentation() is called + self.document.start_presentation() + + # THEN: The presentation should be started + self.mocked_client.start_presentation.assert_called_once_with() + mocked_registry.get.assert_called_once_with('main_window') + mocked_main_window.activateWindow.assert_called_once_with() + + def test_get_slide_number(self): + """ + Test the get_slide_number() method + """ + # GIVEN: A mocked client + self.mocked_client.get_slide_number.return_value = 5 + + # WHEN: get_slide_number() is called + result = self.document.get_slide_number() + + # THEN: Then the result should be correct + assert result == 5 + + def test_get_slide_count(self): + """ + Test the get_slide_count() method + """ + # GIVEN: A mocked client + self.mocked_client.get_slide_count.return_value = 8 + + # WHEN: get_slide_count() is called + result = self.document.get_slide_count() + + # THEN: Then the result should be correct + assert result == 8 + + def test_goto_slide(self): + """ + Test the goto_slide() method + """ + # GIVEN: A mocked client + # WHEN: goto_slide() is called + self.document.goto_slide(3) + + # THEN: Then the result should be correct + self.mocked_client.goto_slide.assert_called_once_with(3) + + def test_next_step(self): + """ + Test the next_step() method + """ + # GIVEN: A mocked client + # WHEN: next_step() is called + self.document.next_step() + + # THEN: Then the result should be correct + self.mocked_client.next_step.assert_called_once_with() + + def test_previous_step(self): + """ + Test the previous_step() method + """ + # GIVEN: A mocked client + # WHEN: previous_step() is called + self.document.previous_step() + + # THEN: Then the result should be correct + self.mocked_client.previous_step.assert_called_once_with() + + def test_get_slide_text(self): + """ + Test the get_slide_text() method + """ + # GIVEN: A mocked client + self.mocked_client.get_slide_text.return_value = 'Some slide text' + + # WHEN: get_slide_text() is called + result = self.document.get_slide_text(1) + + # THEN: Then the result should be correct + self.mocked_client.get_slide_text.assert_called_once_with(1) + assert result == 'Some slide text' + + def test_get_slide_notes(self): + """ + Test the get_slide_notes() method + """ + # GIVEN: A mocked client + self.mocked_client.get_slide_notes.return_value = 'This is a note' + + # WHEN: get_slide_notes() is called + result = self.document.get_slide_notes(2) + + # THEN: Then the result should be correct + self.mocked_client.get_slide_notes.assert_called_once_with(2) + assert result == 'This is a note'