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/display/render.py b/openlp/core/display/render.py index 05b7b97fc..1097b5ac0 100644 --- a/openlp/core/display/render.py +++ b/openlp/core/display/render.py @@ -24,6 +24,7 @@ The :mod:`~openlp.display.render` module contains functions for rendering. """ import html import logging +import mako import math import os import re @@ -32,8 +33,10 @@ import time from PyQt5 import QtWidgets, QtGui from openlp.core.common import ThemeLevel +from openlp.core.common.i18n import translate from openlp.core.common.mixins import LogMixin, RegistryProperties from openlp.core.common.registry import Registry, RegistryBase +from openlp.core.common.settings import Settings from openlp.core.display.screens import ScreenList from openlp.core.display.window import DisplayWindow from openlp.core.lib import ItemCapabilities @@ -58,8 +61,10 @@ VERSE = 'The Lord said to {r}Noah{/r}: \n' \ '{r}C{/r}{b}h{/b}{bl}i{/bl}{y}l{/y}{g}d{/g}{pk}' \ 'r{/pk}{o}e{/o}{pp}n{/pp} of the Lord\n' VERSE_FOR_LINE_COUNT = '\n'.join(map(str, range(100))) -TITLE = 'Arky Arky (Unknown)' -FOOTER = ['Public Domain', 'CCLI 123456'] +TITLE = 'Arky Arky' +AUTHOR = 'John Doe' +FOOTER_COPYRIGHT = 'Public Domain' +CCLI_NO = '123456' def remove_tags(text, can_remove_chords=False): @@ -425,7 +430,7 @@ def get_start_tags(raw_text): return raw_text + ''.join(end_tags), ''.join(start_tags), ''.join(html_tags) -class Renderer(RegistryBase, LogMixin, RegistryProperties, DisplayWindow): +class ThemePreviewRenderer(LogMixin, DisplayWindow): """ A virtual display used for rendering thumbnails and other offscreen tasks """ @@ -435,24 +440,6 @@ class Renderer(RegistryBase, LogMixin, RegistryProperties, DisplayWindow): """ super().__init__(*args, **kwargs) self.force_page = False - for screen in ScreenList(): - if screen.is_display: - self.setGeometry(screen.display_geometry.x(), screen.display_geometry.y(), - screen.display_geometry.width(), screen.display_geometry.height()) - break - # If the display is not show'ed and hidden like this webegine will not render - self.show() - self.hide() - self.theme_height = 0 - self.theme_level = ThemeLevel.Global - - def set_theme_level(self, theme_level): - """ - Sets the theme level. - - :param theme_level: The theme level to be used. - """ - self.theme_level = theme_level def calculate_line_count(self): """ @@ -466,7 +453,30 @@ class Renderer(RegistryBase, LogMixin, RegistryProperties, DisplayWindow): """ return self.run_javascript('Display.clearSlides();') - def generate_preview(self, theme_data, force_page=False): + def generate_footer(self): + """ + """ + footer_template = Settings().value('songs/footer template') + # Keep this in sync with the list in songstab.py + vars = { + 'title': TITLE, + 'authors_none_label': translate('OpenLP.Ui', 'Written by'), + 'authors_words_label': translate('SongsPlugin.AuthorType', 'Words', + 'Author who wrote the lyrics of a song'), + 'authors_words': [AUTHOR], + 'copyright': FOOTER_COPYRIGHT, + 'ccli_license': Settings().value('core/ccli number'), + 'ccli_license_label': translate('SongsPlugin.MediaItem', 'CCLI License'), + 'ccli_number': CCLI_NO, + } + try: + footer_html = mako.template.Template(footer_template).render_unicode(**vars).replace('\n', '') + except mako.exceptions.SyntaxException: + log.error('Failed to render Song footer html:\n' + mako.exceptions.text_error_template().render()) + footer_html = 'Dummy footer text' + return footer_html + + def generate_preview(self, theme_data, force_page=False, generate_screenshot=True): """ Generate a preview of a theme. @@ -479,14 +489,16 @@ class Renderer(RegistryBase, LogMixin, RegistryProperties, DisplayWindow): if not self.force_page: self.set_theme(theme_data) self.theme_height = theme_data.font_main_height - slides = self.format_slide(render_tags(VERSE), None) + slides = self.format_slide(VERSE, None) verses = dict() verses['title'] = TITLE - verses['text'] = slides[0] + verses['text'] = render_tags(slides[0]) verses['verse'] = 'V1' + verses['footer'] = self.generate_footer() self.load_verses([verses]) self.force_page = False - return self.save_screenshot() + if generate_screenshot: + return self.save_screenshot() self.force_page = False return None @@ -515,7 +527,7 @@ class Renderer(RegistryBase, LogMixin, RegistryProperties, DisplayWindow): if item and item.is_capable(ItemCapabilities.CanWordSplit): pages = self._paginate_slide_words(text.split('\n'), line_end) # Songs and Custom - elif item is None or item.is_capable(ItemCapabilities.CanSoftBreak): + elif item is None or (item and item.is_capable(ItemCapabilities.CanSoftBreak)): pages = [] if '[---]' in text: # Remove Overflow split if at start of the text @@ -722,7 +734,8 @@ class Renderer(RegistryBase, LogMixin, RegistryProperties, DisplayWindow): :param text: The text to check. It may contain HTML tags. """ self.clear_slides() - self.run_javascript('Display.addTextSlide("v1", "{text}", "Dummy Footer");'.format(text=text), is_sync=True) + self.run_javascript('Display.addTextSlide("v1", "{text}", "Dummy Footer");' + .format(text=text.replace('"', '\\"')), is_sync=True) does_text_fits = self.run_javascript('Display.doesContentFit();', is_sync=True) return does_text_fits @@ -745,3 +758,33 @@ class Renderer(RegistryBase, LogMixin, RegistryProperties, DisplayWindow): pixmap.save(fname, ext) else: return pixmap + + +class Renderer(RegistryBase, RegistryProperties, ThemePreviewRenderer): + """ + A virtual display used for rendering thumbnails and other offscreen tasks + """ + def __init__(self, *args, **kwargs): + """ + Constructor + """ + super().__init__(*args, **kwargs) + self.force_page = False + for screen in ScreenList(): + if screen.is_display: + self.setGeometry(screen.display_geometry.x(), screen.display_geometry.y(), + screen.display_geometry.width(), screen.display_geometry.height()) + break + # If the display is not show'ed and hidden like this webegine will not render + self.show() + self.hide() + self.theme_height = 0 + self.theme_level = ThemeLevel.Global + + def set_theme_level(self, theme_level): + """ + Sets the theme level. + + :param theme_level: The theme level to be used. + """ + self.theme_level = theme_level 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/core/ui/media/vlcplayer.py b/openlp/core/ui/media/vlcplayer.py index 3904b63ae..244916d24 100644 --- a/openlp/core/ui/media/vlcplayer.py +++ b/openlp/core/ui/media/vlcplayer.py @@ -28,7 +28,6 @@ import os import sys import threading from datetime import datetime -import vlc from PyQt5 import QtWidgets @@ -62,25 +61,27 @@ def get_vlc(): :return: The "vlc" module, or None """ - if 'vlc' in sys.modules: - # If VLC has already been imported, no need to do all the stuff below again - is_vlc_available = False + # Import the VLC module if not already done + if 'vlc' not in sys.modules: try: - is_vlc_available = bool(sys.modules['vlc'].get_default_instance()) - except Exception: - pass - if is_vlc_available: - return sys.modules['vlc'] - else: + import vlc # noqa module is not used directly, but is used via sys.modules['vlc'] + except ImportError: return None - else: - return vlc + # Verify that VLC is also loadable + is_vlc_available = False + try: + is_vlc_available = bool(sys.modules['vlc'].get_default_instance()) + except Exception: + pass + if is_vlc_available: + return sys.modules['vlc'] + return None # On linux we need to initialise X threads, but not when running tests. # This needs to happen on module load and not in get_vlc(), otherwise it can cause crashes on some DE on some setups # (reported on Gnome3, Unity, Cinnamon, all GTK+ based) when using native filedialogs... -if is_linux() and 'nose' not in sys.argv[0] and get_vlc(): +if is_linux() and 'pytest' not in sys.argv[0] and get_vlc(): try: try: x11 = ctypes.cdll.LoadLibrary('libX11.so.6') diff --git a/openlp/core/ui/themeform.py b/openlp/core/ui/themeform.py index f165d32dc..183993500 100644 --- a/openlp/core/ui/themeform.py +++ b/openlp/core/ui/themeform.py @@ -172,16 +172,14 @@ class ThemeForm(QtWidgets.QWizard, Ui_ThemeWizard, RegistryProperties): if not event: event = QtGui.QResizeEvent(self.size(), self.size()) QtWidgets.QWizard.resizeEvent(self, event) - if hasattr(self, 'preview_page') and self.currentPage() == self.preview_page: - frame_width = self.preview_box_label.lineWidth() - pixmap_width = self.preview_area.width() - 2 * frame_width - pixmap_height = self.preview_area.height() - 2 * frame_width - aspect_ratio = float(pixmap_width) / pixmap_height - if aspect_ratio < self.display_aspect_ratio: - pixmap_height = int(pixmap_width / self.display_aspect_ratio + 0.5) - else: - pixmap_width = int(pixmap_height * self.display_aspect_ratio + 0.5) - self.preview_box_label.setFixedSize(pixmap_width + 2 * frame_width, pixmap_height + 2 * frame_width) + try: + self.display_aspect_ratio = self.renderer.width() / self.renderer.height() + except ZeroDivisionError: + self.display_aspect_ratio = 1 + # Make sure we don't resize before the widgets are actually created + if hasattr(self, 'preview_area_layout'): + self.preview_area_layout.set_aspect_ratio(self.display_aspect_ratio) + self.preview_box.set_scale(float(self.preview_box.width()) / self.renderer.width()) def validateCurrentPage(self): """ @@ -206,11 +204,17 @@ class ThemeForm(QtWidgets.QWizard, Ui_ThemeWizard, RegistryProperties): self.setOption(QtWidgets.QWizard.HaveCustomButton1, enabled) if self.page(page_id) == self.preview_page: self.update_theme() - frame = self.theme_manager.generate_image(self.theme) - frame.setDevicePixelRatio(self.devicePixelRatio()) - self.preview_box_label.setPixmap(frame) - self.display_aspect_ratio = float(frame.width()) / frame.height() + self.preview_box.set_theme(self.theme) + self.preview_box.clear_slides() + self.preview_box.set_scale(float(self.preview_box.width()) / self.renderer.width()) + try: + self.display_aspect_ratio = self.renderer.width() / self.renderer.height() + except ZeroDivisionError: + self.display_aspect_ratio = 1 + self.preview_area_layout.set_aspect_ratio(self.display_aspect_ratio) self.resizeEvent() + self.preview_box.show() + self.preview_box.generate_preview(self.theme, False, False) def on_custom_1_button_clicked(self, number): """ @@ -398,6 +402,7 @@ class ThemeForm(QtWidgets.QWizard, Ui_ThemeWizard, RegistryProperties): Handle the display and state of the Preview page. """ self.setField('name', self.theme.theme_name) + self.preview_box.set_theme(self.theme) def on_background_combo_box_current_index_changed(self, index): """ @@ -558,5 +563,5 @@ class ThemeForm(QtWidgets.QWizard, Ui_ThemeWizard, RegistryProperties): source_path = self.theme.background_filename if not self.edit_mode and not self.theme_manager.check_if_theme_exists(self.theme.theme_name): return - self.theme_manager.save_theme(self.theme, source_path, destination_path) + self.theme_manager.save_theme(self.theme, source_path, destination_path, self.preview_box.save_screenshot()) return QtWidgets.QDialog.accept(self) diff --git a/openlp/core/ui/thememanager.py b/openlp/core/ui/thememanager.py index fd003ed2e..46d067970 100644 --- a/openlp/core/ui/thememanager.py +++ b/openlp/core/ui/thememanager.py @@ -476,7 +476,7 @@ class ThemeManager(QtWidgets.QWidget, RegistryBase, Ui_ThemeManager, LogMixin, R if not theme_paths: theme = Theme() theme.theme_name = UiStrings().Default - self._write_theme(theme) + self.save_theme(theme) Settings().setValue(self.settings_section + '/global theme', theme.theme_name) self.application.set_normal_cursor() @@ -639,24 +639,14 @@ class ThemeManager(QtWidgets.QWidget, RegistryBase, Ui_ThemeManager, LogMixin, R return False return True - def save_theme(self, theme, image_source_path, image_destination_path): - """ - Called by theme maintenance Dialog to save the theme and to trigger the reload of the theme list - - :param Theme theme: The theme data object. - :param Path image_source_path: Where the theme image is currently located. - :param Path image_destination_path: Where the Theme Image is to be saved to - :rtype: None - """ - self._write_theme(theme, image_source_path, image_destination_path) - - def _write_theme(self, theme, image_source_path=None, image_destination_path=None): + def save_theme(self, theme, image_source_path=None, image_destination_path=None, image=None): """ Writes the theme to the disk and handles the background image if necessary :param Theme theme: The theme data object. :param Path image_source_path: Where the theme image is currently located. :param Path image_destination_path: Where the Theme Image is to be saved to + :param image: The example image of the theme. Optionally. :rtype: None """ name = theme.theme_name @@ -676,7 +666,15 @@ class ThemeManager(QtWidgets.QWidget, RegistryBase, Ui_ThemeManager, LogMixin, R shutil.copyfile(image_source_path, image_destination_path) except OSError: self.log_exception('Failed to save theme image') - self.generate_and_save_image(name, theme) + if image: + sample_path_name = self.theme_path / '{file_name}.png'.format(file_name=name) + if sample_path_name.exists(): + sample_path_name.unlink() + image.save(str(sample_path_name), 'png') + thumb_path = self.thumb_path / '{name}.png'.format(name=name) + create_thumb(sample_path_name, thumb_path, False) + else: + self.generate_and_save_image(name, theme) def generate_and_save_image(self, theme_name, theme): """ diff --git a/openlp/core/ui/themewizard.py b/openlp/core/ui/themewizard.py index e6a7ac2c5..a60e0dc7f 100644 --- a/openlp/core/ui/themewizard.py +++ b/openlp/core/ui/themewizard.py @@ -31,6 +31,8 @@ from openlp.core.lib.ui import add_welcome_page, create_valign_selection_widgets from openlp.core.ui.icons import UiIcons from openlp.core.widgets.buttons import ColorButton from openlp.core.widgets.edits import PathEdit +from openlp.core.widgets.layouts import AspectRatioLayout +from openlp.core.display.render import ThemePreviewRenderer class Ui_ThemeWizard(object): @@ -363,16 +365,13 @@ class Ui_ThemeWizard(object): self.preview_layout.addLayout(self.theme_name_layout) self.preview_area = QtWidgets.QWidget(self.preview_page) self.preview_area.setObjectName('PreviewArea') - self.preview_area_layout = QtWidgets.QGridLayout(self.preview_area) - self.preview_area_layout.setContentsMargins(0, 0, 0, 0) - self.preview_area_layout.setColumnStretch(0, 1) - self.preview_area_layout.setRowStretch(0, 1) - self.preview_area_layout.setObjectName('preview_area_layout') - self.preview_box_label = QtWidgets.QLabel(self.preview_area) - self.preview_box_label.setFrameShape(QtWidgets.QFrame.Box) - self.preview_box_label.setScaledContents(True) - self.preview_box_label.setObjectName('preview_box_label') - self.preview_area_layout.addWidget(self.preview_box_label) + self.preview_area_layout = AspectRatioLayout(self.preview_area, 0.75) # Dummy ratio, will be update + self.preview_area_layout.margin = 8 + self.preview_area_layout.setSpacing(0) + self.preview_area_layout.setObjectName('preview_web_layout') + self.preview_box = ThemePreviewRenderer(self) + self.preview_box.setObjectName('preview_box') + self.preview_area_layout.addWidget(self.preview_box) self.preview_layout.addWidget(self.preview_area) theme_wizard.addPage(self.preview_page) self.retranslate_ui(theme_wizard) diff --git a/openlp/plugins/media/lib/mediaitem.py b/openlp/plugins/media/lib/mediaitem.py index 58994a909..30962967a 100644 --- a/openlp/plugins/media/lib/mediaitem.py +++ b/openlp/plugins/media/lib/mediaitem.py @@ -173,7 +173,7 @@ class MediaMediaItem(MediaManagerItem, RegistryProperties): item = self.list_view.currentItem() if item is None: return False - filename = item.data(QtCore.Qt.UserRole) + filename = str(item.data(QtCore.Qt.UserRole)) # Special handling if the filename is a optical clip if filename.startswith('optical:'): (name, title, audio_track, subtitle_track, start, end, clip_name) = parse_optical_path(filename) @@ -259,11 +259,12 @@ class MediaMediaItem(MediaManagerItem, RegistryProperties): # TODO needs to be fixed as no idea why this fails # media.sort(key=lambda file_path: get_natural_key(file_path.name)) for track in media: - track_info = QtCore.QFileInfo(track) + track_str = str(track) + track_info = QtCore.QFileInfo(track_str) item_name = None - if track.startswith('optical:'): + if track_str.startswith('optical:'): # Handle optical based item - (file_name, title, audio_track, subtitle_track, start, end, clip_name) = parse_optical_path(track) + (file_name, title, audio_track, subtitle_track, start, end, clip_name) = parse_optical_path(track_str) item_name = QtWidgets.QListWidgetItem(clip_name) item_name.setIcon(UiIcons().optical) item_name.setData(QtCore.Qt.UserRole, track) @@ -272,22 +273,22 @@ class MediaMediaItem(MediaManagerItem, RegistryProperties): end=format_milliseconds(end))) elif not os.path.exists(track): # File doesn't exist, mark as error. - file_name = os.path.split(str(track))[1] + file_name = os.path.split(track_str)[1] item_name = QtWidgets.QListWidgetItem(file_name) item_name.setIcon(UiIcons().error) item_name.setData(QtCore.Qt.UserRole, track) - item_name.setToolTip(track) + item_name.setToolTip(track_str) elif track_info.isFile(): # Normal media file handling. - file_name = os.path.split(str(track))[1] + file_name = os.path.split(track_str)[1] item_name = QtWidgets.QListWidgetItem(file_name) search = file_name.split('.')[-1].lower() - if '*.{text}'.format(text=search) in self.media_controller.audio_extensions_list: + if search in AUDIO_EXT: item_name.setIcon(UiIcons().audio) else: item_name.setIcon(UiIcons().video) item_name.setData(QtCore.Qt.UserRole, track) - item_name.setToolTip(track) + item_name.setToolTip(track_str) if item_name: self.list_view.addItem(item_name) 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/openlp/plugins/songs/lib/songstab.py b/openlp/plugins/songs/lib/songstab.py index 34b83a8e5..0abd6edda 100644 --- a/openlp/plugins/songs/lib/songstab.py +++ b/openlp/plugins/songs/lib/songstab.py @@ -88,7 +88,7 @@ class SongsTab(SettingsTab): self.footer_group_box = QtWidgets.QGroupBox(self.left_column) self.footer_group_box.setObjectName('footer_group_box') self.footer_layout = QtWidgets.QVBoxLayout(self.footer_group_box) - self.footer_layout.setObjectName('chords_layout') + self.footer_layout.setObjectName('footer_layout') self.footer_info_label = QtWidgets.QLabel(self.footer_group_box) self.footer_layout.addWidget(self.footer_info_label) self.footer_placeholder_info = QtWidgets.QTextEdit(self.footer_group_box) 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_core/ui/test_thememanager.py b/tests/functional/openlp_core/ui/test_thememanager.py index b4d50331b..3b015f238 100644 --- a/tests/functional/openlp_core/ui/test_thememanager.py +++ b/tests/functional/openlp_core/ui/test_thememanager.py @@ -83,7 +83,7 @@ class TestThemeManager(TestCase): @patch('openlp.core.ui.thememanager.shutil') @patch('openlp.core.ui.thememanager.create_paths') - def test_write_theme_same_image(self, mocked_create_paths, mocked_shutil): + def test_save_theme_same_image(self, mocked_create_paths, mocked_shutil): """ Test that we don't try to overwrite a theme background image with itself """ @@ -98,16 +98,16 @@ class TestThemeManager(TestCase): mocked_theme.extract_formatted_xml = MagicMock() mocked_theme.extract_formatted_xml.return_value = 'fake_theme_xml'.encode() - # WHEN: Calling _write_theme with path to the same image, but the path written slightly different + # WHEN: Calling save_theme with path to the same image, but the path written slightly different file_path_1 = RESOURCE_PATH / 'church.jpg' - theme_manager._write_theme(mocked_theme, file_path_1, file_path_1) + theme_manager.save_theme(mocked_theme, file_path_1, file_path_1) # THEN: The mocked_copyfile should not have been called assert mocked_shutil.copyfile.called is False, 'copyfile should not be called' @patch('openlp.core.ui.thememanager.shutil') @patch('openlp.core.ui.thememanager.create_paths') - def test_write_theme_diff_images(self, mocked_create_paths, mocked_shutil): + def test_save_theme_diff_images(self, mocked_create_paths, mocked_shutil): """ Test that we do overwrite a theme background image when a new is submitted """ @@ -121,15 +121,15 @@ class TestThemeManager(TestCase): mocked_theme.theme_name = 'themename' mocked_theme.filename = "filename" - # WHEN: Calling _write_theme with path to different images + # WHEN: Calling save_theme with path to different images file_path_1 = RESOURCE_PATH / 'church.jpg' file_path_2 = RESOURCE_PATH / 'church2.jpg' - theme_manager._write_theme(mocked_theme, file_path_1, file_path_2) + theme_manager.save_theme(mocked_theme, file_path_1, file_path_2) # THEN: The mocked_copyfile should not have been called assert mocked_shutil.copyfile.called is True, 'copyfile should be called' - def test_write_theme_special_char_name(self): + def test_save_theme_special_char_name(self): """ Test that we can save themes with special characters in the name """ @@ -142,8 +142,8 @@ class TestThemeManager(TestCase): mocked_theme.theme_name = 'theme 愛 name' mocked_theme.export_theme.return_value = "{}" - # WHEN: Calling _write_theme with a theme with a name with special characters in it - theme_manager._write_theme(mocked_theme) + # WHEN: Calling save_theme with a theme with a name with special characters in it + theme_manager.save_theme(mocked_theme) # THEN: It should have been created assert os.path.exists(os.path.join(self.temp_folder, 'theme 愛 name', 'theme 愛 name.json')) is True, \ 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' diff --git a/tests/openlp_core/ui/test_themeform.py b/tests/openlp_core/ui/test_themeform.py index 87a5f3bf6..02a7d0589 100644 --- a/tests/openlp_core/ui/test_themeform.py +++ b/tests/openlp_core/ui/test_themeform.py @@ -23,6 +23,7 @@ Interface tests to test the ThemeWizard class and related methods. """ from unittest import TestCase +from unittest.mock import patch from openlp.core.common.registry import Registry from openlp.core.ui.themeform import ThemeForm @@ -39,7 +40,8 @@ class TestThemeManager(TestCase, TestMixin): """ Registry.create() - def test_create_theme_wizard(self): + @patch('openlp.core.display.window.QtWidgets.QVBoxLayout') + def test_create_theme_wizard(self, mocked_qvboxlayout): """ Test creating a ThemeForm instance """