← Back to team overview

openlp-core team mailing list archive

[Merge] lp:~raoul-snyman/openlp/pyro-impress into lp:openlp

 

Raoul Snyman has proposed merging lp:~raoul-snyman/openlp/pyro-impress into lp:openlp.

Requested reviews:
  Tim Bentley (trb143)
  Tomas Groth (tomasgroth)

For more details, see:
https://code.launchpad.net/~raoul-snyman/openlp/pyro-impress/+merge/368362

Add presentations through LibreOffice on macOS. Comments and criticisms welcome.
-- 
Your team OpenLP Core is subscribed to branch lp:openlp.
=== modified file '.bzrignore'
--- .bzrignore	2019-03-25 21:24:51 +0000
+++ .bzrignore	2019-06-05 05:00:52 +0000
@@ -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

=== modified file 'openlp/core/common/path.py'
--- openlp/core/common/path.py	2019-05-22 06:47:00 +0000
+++ openlp/core/common/path.py	2019-06-05 05:00:52 +0000
@@ -78,6 +78,8 @@
     :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:

=== modified file 'openlp/core/ui/media/mediacontroller.py'
--- openlp/core/ui/media/mediacontroller.py	2019-06-01 06:59:45 +0000
+++ openlp/core/ui/media/mediacontroller.py	2019-06-05 05:00:52 +0000
@@ -104,6 +104,8 @@
             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

=== added file 'openlp/plugins/presentations/lib/libreofficeserver.py'
--- openlp/plugins/presentations/lib/libreofficeserver.py	1970-01-01 00:00:00 +0000
+++ openlp/plugins/presentations/lib/libreofficeserver.py	2019-06-05 05:00:52 +0000
@@ -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 <https://www.gnu.org/licenses/>. #
+##########################################################################
+"""
+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()

=== added file 'openlp/plugins/presentations/lib/maclocontroller.py'
--- openlp/plugins/presentations/lib/maclocontroller.py	1970-01-01 00:00:00 +0000
+++ openlp/plugins/presentations/lib/maclocontroller.py	2019-06-05 05:00:52 +0000
@@ -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 <https://www.gnu.org/licenses/>. #
+##########################################################################
+
+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)

=== modified file 'openlp/plugins/presentations/lib/presentationcontroller.py'
--- openlp/plugins/presentations/lib/presentationcontroller.py	2019-05-22 06:47:00 +0000
+++ openlp/plugins/presentations/lib/presentationcontroller.py	2019-06-05 05:00:52 +0000
@@ -410,7 +410,8 @@
     """
     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
 
@@ -430,6 +431,7 @@
         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

=== modified file 'openlp/plugins/presentations/lib/presentationtab.py'
--- openlp/plugins/presentations/lib/presentationtab.py	2019-05-22 06:47:00 +0000
+++ openlp/plugins/presentations/lib/presentationtab.py	2019-06-05 05:00:52 +0000
@@ -127,10 +127,10 @@
 
     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):
         """

=== added file 'openlp/plugins/presentations/lib/serializers.py'
--- openlp/plugins/presentations/lib/serializers.py	1970-01-01 00:00:00 +0000
+++ openlp/plugins/presentations/lib/serializers.py	2019-06-05 05:00:52 +0000
@@ -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 <https://www.gnu.org/licenses/>. #
+##########################################################################
+"""
+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)

=== added directory 'openlp/plugins/presentations/lib/vendor'
=== added file 'openlp/plugins/presentations/lib/vendor/do_not_delete.txt'
--- openlp/plugins/presentations/lib/vendor/do_not_delete.txt	1970-01-01 00:00:00 +0000
+++ openlp/plugins/presentations/lib/vendor/do_not_delete.txt	2019-06-05 05:00:52 +0000
@@ -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.
+

=== modified file 'openlp/plugins/presentations/presentationplugin.py'
--- openlp/plugins/presentations/presentationplugin.py	2019-04-13 13:00:22 +0000
+++ openlp/plugins/presentations/presentationplugin.py	2019-06-05 05:00:52 +0000
@@ -28,13 +28,13 @@
 
 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 @@
 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 @@
                 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()
 

=== modified file 'scripts/check_dependencies.py'
--- scripts/check_dependencies.py	2019-05-05 08:13:10 +0000
+++ scripts/check_dependencies.py	2019-06-05 05:00:52 +0000
@@ -159,6 +159,8 @@
         w('OK')
     except ImportError:
         w('FAIL')
+    except Exception:
+        w('ERROR')
     w(os.linesep)
 
 

=== modified file 'tests/functional/openlp_core/common/test_path.py'
--- tests/functional/openlp_core/common/test_path.py	2019-05-26 10:30:37 +0000
+++ tests/functional/openlp_core/common/test_path.py	2019-06-05 05:00:52 +0000
@@ -110,7 +110,18 @@
         # 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):
         """

=== added file 'tests/functional/openlp_plugins/presentations/test_libreofficeserver.py'
--- tests/functional/openlp_plugins/presentations/test_libreofficeserver.py	1970-01-01 00:00:00 +0000
+++ tests/functional/openlp_plugins/presentations/test_libreofficeserver.py	2019-06-05 05:00:52 +0000
@@ -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 <https://www.gnu.org/licenses/>. #
+##########################################################################
+"""
+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()

=== added file 'tests/functional/openlp_plugins/presentations/test_maclocontroller.py'
--- tests/functional/openlp_plugins/presentations/test_maclocontroller.py	1970-01-01 00:00:00 +0000
+++ tests/functional/openlp_plugins/presentations/test_maclocontroller.py	2019-06-05 05:00:52 +0000
@@ -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 <https://www.gnu.org/licenses/>. #
+##########################################################################
+"""
+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'


Follow ups