diff --git a/openlp/core/lib/filedialog.py b/openlp/core/common/path.py similarity index 55% rename from openlp/core/lib/filedialog.py rename to openlp/core/common/path.py index 5fd8015e3..12bef802d 100644 --- a/openlp/core/lib/filedialog.py +++ b/openlp/core/common/path.py @@ -19,40 +19,43 @@ # with this program; if not, write to the Free Software Foundation, Inc., 59 # # Temple Place, Suite 330, Boston, MA 02111-1307 USA # ############################################################################### -""" -Provide a work around for a bug in QFileDialog -""" -import logging -import os -from urllib import parse -from PyQt5 import QtWidgets - -from openlp.core.common import UiStrings - -log = logging.getLogger(__name__) +from pathlib import Path -class FileDialog(QtWidgets.QFileDialog): +def path_to_str(path): """ - Subclass QFileDialog to work round a bug + A utility function to convert a Path object or NoneType to a string equivalent. + + :param path: The value to convert to a string + :type: pathlib.Path or None + + :return: An empty string if :param:`path` is None, else a string representation of the :param:`path` + :rtype: str """ - @staticmethod - def getOpenFileNames(parent, *args, **kwargs): - """ - Reimplement getOpenFileNames to fix the way it returns some file names that url encoded when selecting multiple - files - """ - files, filter_used = QtWidgets.QFileDialog.getOpenFileNames(parent, *args, **kwargs) - file_list = [] - for file in files: - if not os.path.exists(file): - log.info('File not found. Attempting to unquote.') - file = parse.unquote(file) - if not os.path.exists(file): - log.error('File {text} not found.'.format(text=file)) - QtWidgets.QMessageBox.information(parent, UiStrings().FileNotFound, - UiStrings().FileNotFoundMessage.format(name=file)) - continue - file_list.append(file) - return file_list + 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: + return '' + else: + return str(path) + + +def str_to_path(string): + """ + A utility function to convert a str object to a Path or NoneType. + + This function is of particular use because initating a Path object with an empty string causes the Path object to + point to the current working directory. + + :param string: The string to convert + :type string: str + + :return: None if :param:`string` is empty, or a Path object representation of :param:`string` + :rtype: pathlib.Path or None + """ + if not isinstance(string, str): + raise TypeError('parameter \'string\' must be of type str') + if string == '': + return None + return Path(string) diff --git a/openlp/core/lib/__init__.py b/openlp/core/lib/__init__.py index a8b5771b6..77fefee39 100644 --- a/openlp/core/lib/__init__.py +++ b/openlp/core/lib/__init__.py @@ -608,8 +608,42 @@ def create_separated_list(string_list): return list_to_string +def replace_params(args, kwargs, params): + """ + Apply a transformation function to the specified args or kwargs + + :param args: Positional arguments + :type args: (,) + + :param kwargs: Key Word arguments + :type kwargs: dict + + :param params: A tuple of tuples with the position and the key word to replace. + :type params: ((int, str, path_to_str),) + + :return: The modified positional and keyword arguments + :rtype: (tuple, dict) + + + Usage: + Take a method with the following signature, and assume we which to apply the str function to arg2: + def method(arg1=None, arg2=None, arg3=None) + + As arg2 can be specified postitionally as the second argument (1 with a zero index) or as a keyword, the we + would call this function as follows: + + replace_params(args, kwargs, ((1, 'arg2', str),)) + """ + args = list(args) + for position, key_word, transform in params: + if len(args) > position: + args[position] = transform(args[position]) + elif key_word in kwargs: + kwargs[key_word] = transform(kwargs[key_word]) + return tuple(args), kwargs + + from .exceptions import ValidationError -from .filedialog import FileDialog from .screen import ScreenList from .formattingtags import FormattingTags from .plugin import PluginStatus, StringContent, Plugin @@ -621,5 +655,5 @@ from .imagemanager import ImageManager from .renderer import Renderer from .mediamanageritem import MediaManagerItem from .projector.db import ProjectorDB, Projector -from .projector.pjlink1 import PJLink +from .projector.pjlink import PJLink from .projector.constants import PJLINK_PORT, ERROR_MSG, ERROR_STRING diff --git a/openlp/core/lib/mediamanageritem.py b/openlp/core/lib/mediamanageritem.py index 0adb471f4..6b04d076b 100644 --- a/openlp/core/lib/mediamanageritem.py +++ b/openlp/core/lib/mediamanageritem.py @@ -26,12 +26,14 @@ import logging import os import re -from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt5 import QtCore, QtWidgets from openlp.core.common import Registry, RegistryProperties, Settings, UiStrings, translate -from openlp.core.lib import FileDialog, ServiceItem, StringContent, ServiceItemContext +from openlp.core.common.path import path_to_str, str_to_path +from openlp.core.lib import ServiceItem, StringContent, ServiceItemContext from openlp.core.lib.searchedit import SearchEdit from openlp.core.lib.ui import create_widget_action, critical_error_message_box +from openlp.core.ui.lib.filedialog import FileDialog from openlp.core.ui.lib.listwidgetwithdnd import ListWidgetWithDnD from openlp.core.ui.lib.toolbar import OpenLPToolbar @@ -309,13 +311,14 @@ class MediaManagerItem(QtWidgets.QWidget, RegistryProperties): """ Add a file to the list widget to make it available for showing """ - files = FileDialog.getOpenFileNames(self, self.on_new_prompt, - Settings().value(self.settings_section + '/last directory'), - self.on_new_file_masks) - log.info('New files(s) {files}'.format(files=files)) - if files: + file_paths, selected_filter = FileDialog.getOpenFileNames( + self, self.on_new_prompt, + str_to_path(Settings().value(self.settings_section + '/last directory')), + self.on_new_file_masks) + log.info('New files(s) {file_paths}'.format(file_paths=file_paths)) + if file_paths: self.application.set_busy_cursor() - self.validate_and_load(files) + self.validate_and_load([path_to_str(path) for path in file_paths]) self.application.set_normal_cursor() def load_file(self, data): diff --git a/openlp/core/lib/projector/constants.py b/openlp/core/lib/projector/constants.py index d4e6904e4..f2d78e31c 100644 --- a/openlp/core/lib/projector/constants.py +++ b/openlp/core/lib/projector/constants.py @@ -46,7 +46,7 @@ __all__ = ['S_OK', 'E_GENERAL', 'E_NOT_CONNECTED', 'E_FAN', 'E_LAMP', 'E_TEMP', 'S_NOT_CONNECTED', 'S_CONNECTING', 'S_CONNECTED', 'S_STATUS', 'S_OFF', 'S_INITIALIZE', 'S_STANDBY', 'S_WARMUP', 'S_ON', 'S_COOLDOWN', 'S_INFO', 'S_NETWORK_SENDING', 'S_NETWORK_RECEIVED', - 'ERROR_STRING', 'CR', 'LF', 'PJLINK_ERST_STATUS', 'PJLINK_POWR_STATUS', + 'ERROR_STRING', 'CR', 'LF', 'PJLINK_ERST_DATA', 'PJLINK_ERST_STATUS', 'PJLINK_POWR_STATUS', 'PJLINK_PORT', 'PJLINK_MAX_PACKET', 'TIMEOUT', 'ERROR_MSG', 'PJLINK_ERRORS', 'STATUS_STRING', 'PJLINK_VALID_CMD', 'CONNECTION_ERRORS', 'PJLINK_DEFAULT_SOURCES', 'PJLINK_DEFAULT_CODES', 'PJLINK_DEFAULT_ITEMS'] @@ -154,7 +154,7 @@ PJLINK_VALID_CMD = { }, 'SRCH': {'version': ['2', ], 'description': translate('OpenLP.PJLinkConstants', - 'UDP broadcast search request for available projectors.') + 'UDP broadcast search request for available projectors. Reply is ACKN.') }, 'SVER': {'version': ['2', ], 'description': translate('OpenLP.PJLinkConstants', @@ -393,11 +393,32 @@ ERROR_MSG = { S_NETWORK_RECEIVED: translate('OpenLP.ProjectorConstants', 'Received data') } +# Map ERST return code positions to equipment +PJLINK_ERST_DATA = { + 'DATA_LENGTH': 6, + 0: 'FAN', + 1: 'LAMP', + 2: 'TEMP', + 3: 'COVER', + 4: 'FILTER', + 5: 'OTHER', + 'FAN': 0, + 'LAMP': 1, + 'TEMP': 2, + 'COVER': 3, + 'FILTER': 4, + 'OTHER': 5 +} + # Map for ERST return codes to string PJLINK_ERST_STATUS = { - '0': ERROR_STRING[E_OK], + '0': 'OK', '1': ERROR_STRING[E_WARN], - '2': ERROR_STRING[E_ERROR] + '2': ERROR_STRING[E_ERROR], + 'OK': '0', + E_OK: '0', + E_WARN: '1', + E_ERROR: '2' } # Map for POWR return codes to status code diff --git a/openlp/core/lib/projector/pjlink1.py b/openlp/core/lib/projector/pjlink.py similarity index 87% rename from openlp/core/lib/projector/pjlink1.py rename to openlp/core/lib/projector/pjlink.py index b2b1a4af1..14d91f4ba 100644 --- a/openlp/core/lib/projector/pjlink1.py +++ b/openlp/core/lib/projector/pjlink.py @@ -20,14 +20,17 @@ # Temple Place, Suite 330, Boston, MA 02111-1307 USA # ############################################################################### """ - :mod:`openlp.core.lib.projector.pjlink1` module + :mod:`openlp.core.lib.projector.pjlink` module Provides the necessary functions for connecting to a PJLink-capable projector. - See PJLink Class 1 Specifications for details. - http://pjlink.jbmia.or.jp/english/dl.html - + PJLink Class 1 Specifications. + http://pjlink.jbmia.or.jp/english/dl_class1.html Section 5-1 PJLink Specifications + Section 5-5 Guidelines for Input Terminals + PJLink Class 2 Specifications. + http://pjlink.jbmia.or.jp/english/dl_class2.html + Section 5-1 PJLink Specifications Section 5-5 Guidelines for Input Terminals NOTE: @@ -40,7 +43,7 @@ import logging log = logging.getLogger(__name__) -log.debug('pjlink1 loaded') +log.debug('pjlink loaded') __all__ = ['PJLink'] @@ -51,8 +54,8 @@ from PyQt5 import QtCore, QtNetwork from openlp.core.common import translate, qmd5_hash from openlp.core.lib.projector.constants import CONNECTION_ERRORS, CR, ERROR_MSG, ERROR_STRING, \ - E_AUTHENTICATION, E_CONNECTION_REFUSED, E_GENERAL, E_INVALID_DATA, E_NETWORK, E_NOT_CONNECTED, \ - E_PARAMETER, E_PROJECTOR, E_SOCKET_TIMEOUT, E_UNAVAILABLE, E_UNDEFINED, PJLINK_ERRORS, \ + E_AUTHENTICATION, E_CONNECTION_REFUSED, E_GENERAL, E_INVALID_DATA, E_NETWORK, E_NOT_CONNECTED, E_OK, \ + E_PARAMETER, E_PROJECTOR, E_SOCKET_TIMEOUT, E_UNAVAILABLE, E_UNDEFINED, PJLINK_ERRORS, PJLINK_ERST_DATA, \ PJLINK_ERST_STATUS, PJLINK_MAX_PACKET, PJLINK_PORT, PJLINK_POWR_STATUS, PJLINK_VALID_CMD, \ STATUS_STRING, S_CONNECTED, S_CONNECTING, S_NETWORK_RECEIVED, S_NETWORK_SENDING, \ S_NOT_CONNECTED, S_OFF, S_OK, S_ON, S_STATUS @@ -69,88 +72,17 @@ PJLINK_HEADER = '{prefix}{{linkclass}}'.format(prefix=PJLINK_PREFIX) PJLINK_SUFFIX = CR -class PJLink(QtNetwork.QTcpSocket): +class PJLinkCommands(object): """ - Socket service for connecting to a PJLink-capable projector. + Process replies from PJLink projector. """ - # Signals sent by this module - changeStatus = QtCore.pyqtSignal(str, int, str) - projectorNetwork = QtCore.pyqtSignal(int) # Projector network activity - projectorStatus = QtCore.pyqtSignal(int) # Status update - projectorAuthentication = QtCore.pyqtSignal(str) # Authentication error - projectorNoAuthentication = QtCore.pyqtSignal(str) # PIN set and no authentication needed - projectorReceivedData = QtCore.pyqtSignal() # Notify when received data finished processing - projectorUpdateIcons = QtCore.pyqtSignal() # Update the status icons on toolbar - # New commands available in PJLink Class 2 - pjlink_udp_commands = [ - 'ACKN', - 'ERST', # Class 1 or 2 - 'INPT', # Class 1 or 2 - 'LKUP', - 'POWR', # Class 1 or 2 - 'SRCH' - ] - - def __init__(self, name=None, ip=None, port=PJLINK_PORT, pin=None, *args, **kwargs): + def __init__(self, *args, **kwargs): """ - Setup for instance. - - :param name: Display name - :param ip: IP address to connect to - :param port: Port to use. Default to PJLINK_PORT - :param pin: Access pin (if needed) - - Optional parameters - :param dbid: Database ID number - :param location: Location where projector is physically located - :param notes: Extra notes about the projector - :param poll_time: Time (in seconds) to poll connected projector - :param socket_timeout: Time (in seconds) to abort the connection if no response + Setup for the process commands """ - log.debug('PJlink(args={args} kwargs={kwargs})'.format(args=args, kwargs=kwargs)) - self.name = name - self.ip = ip - self.port = port - self.pin = pin + log.debug('PJlinkCommands(args={args} kwargs={kwargs})'.format(args=args, kwargs=kwargs)) super().__init__() - self.model_lamp = None - self.model_filter = None - self.mac_adx = kwargs.get('mac_adx') - self.serial_no = None - self.serial_no_received = None # Used only if saved serial number is different than received serial number - self.dbid = None - self.db_update = False # Use to check if db needs to be updated prior to exiting - self.location = None - self.notes = None - self.dbid = kwargs.get('dbid') - self.location = kwargs.get('location') - self.notes = kwargs.get('notes') - # Poll time 20 seconds unless called with something else - self.poll_time = 20000 if 'poll_time' not in kwargs else kwargs['poll_time'] * 1000 - # Timeout 5 seconds unless called with something else - self.socket_timeout = 5000 if 'socket_timeout' not in kwargs else kwargs['socket_timeout'] * 1000 - # In case we're called from somewhere that only wants information - self.no_poll = 'no_poll' in kwargs - self.i_am_running = False - self.status_connect = S_NOT_CONNECTED - self.last_command = '' - self.projector_status = S_NOT_CONNECTED - self.error_status = S_OK - # Socket information - # Add enough space to input buffer for extraneous \n \r - self.max_size = PJLINK_MAX_PACKET + 2 - self.setReadBufferSize(self.max_size) - # PJLink information - self.pjlink_class = '1' # Default class - self.reset_information() - # Set from ProjectorManager.add_projector() - self.widget = None # QListBox entry - self.timer = None # Timer that calls the poll_loop - self.send_queue = [] - self.send_busy = False - # Socket timer for some possible brain-dead projectors or network cable pulled - self.socket_timer = None # Map command to function self.pjlink_functions = { 'AVMT': self.process_avmt, @@ -173,29 +105,30 @@ class PJLink(QtNetwork.QTcpSocket): def reset_information(self): """ - Reset projector-specific information to default + Initialize instance variables. Also used to reset projector-specific information to default. """ log.debug('({ip}) reset_information() connect status is {state}'.format(ip=self.ip, state=self.state())) - self.send_queue = [] - self.power = S_OFF - self.pjlink_name = None - self.manufacturer = None - self.model = None - self.serial_no = None + self.fan = None # ERST + self.filter_time = None # FILT + self.lamp = None # LAMP + self.mac_adx_received = None # ACKN + self.manufacturer = None # INF1 + self.model = None # INF2 + self.model_filter = None # RFIL + self.model_lamp = None # RLMP + self.mute = None # AVMT + self.other_info = None # INFO + self.pjlink_class = PJLINK_CLASS # Default class + self.pjlink_name = None # NAME + self.power = S_OFF # POWR + self.serial_no = None # SNUM self.serial_no_received = None - self.sw_version = None + self.sw_version = None # SVER self.sw_version_received = None - self.mac_adx = None - self.shutter = None - self.mute = None - self.lamp = None - self.model_lamp = None - self.fan = None - self.filter_time = None - self.model_filter = None - self.source_available = None - self.source = None - self.other_info = None + self.shutter = None # AVMT + self.source_available = None # INST + self.source = None # INPT + # These should be part of PJLink() class, but set here for convenience if hasattr(self, 'timer'): log.debug('({ip}): Calling timer.stop()'.format(ip=self.ip)) self.timer.stop() @@ -203,6 +136,425 @@ class PJLink(QtNetwork.QTcpSocket): log.debug('({ip}): Calling socket_timer.stop()'.format(ip=self.ip)) self.socket_timer.stop() self.send_busy = False + self.send_queue = [] + + def process_command(self, cmd, data): + """ + Verifies any return error code. Calls the appropriate command handler. + + :param cmd: Command to process + :param data: Data being processed + """ + log.debug('({ip}) Processing command "{cmd}" with data "{data}"'.format(ip=self.ip, + cmd=cmd, + data=data)) + # Check if we have a future command not available yet + _cmd = cmd.upper() + _data = data.upper() + if _cmd not in PJLINK_VALID_CMD: + log.error("({ip}) Ignoring command='{cmd}' (Invalid/Unknown)".format(ip=self.ip, cmd=cmd)) + return + elif _data == 'OK': + log.debug('({ip}) Command "{cmd}" returned OK'.format(ip=self.ip, cmd=cmd)) + # A command returned successfully, no further processing needed + return + elif _cmd not in self.pjlink_functions: + log.warn("({ip}) Unable to process command='{cmd}' (Future option)".format(ip=self.ip, cmd=cmd)) + return + elif _data in PJLINK_ERRORS: + # Oops - projector error + log.error('({ip}) Projector returned error "{data}"'.format(ip=self.ip, data=data)) + if _data == PJLINK_ERRORS[E_AUTHENTICATION]: + # Authentication error + self.disconnect_from_host() + self.change_status(E_AUTHENTICATION) + log.debug('({ip}) emitting projectorAuthentication() signal'.format(ip=self.ip)) + self.projectorAuthentication.emit(self.name) + elif _data == PJLINK_ERRORS[E_UNDEFINED]: + # Projector does not recognize command + self.change_status(E_UNDEFINED, '{error}: "{data}"'.format(error=ERROR_MSG[E_UNDEFINED], + data=cmd)) + elif _data == PJLINK_ERRORS[E_PARAMETER]: + # Invalid parameter + self.change_status(E_PARAMETER) + elif _data == PJLINK_ERRORS[E_UNAVAILABLE]: + # Projector busy + self.change_status(E_UNAVAILABLE) + elif _data == PJLINK_ERRORS[E_PROJECTOR]: + # Projector/display error + self.change_status(E_PROJECTOR) + self.receive_data_signal() + return + # Command checks already passed + log.debug('({ip}) Calling function for {cmd}'.format(ip=self.ip, cmd=cmd)) + self.receive_data_signal() + self.pjlink_functions[_cmd](data) + + def process_avmt(self, data): + """ + Process shutter and speaker status. See PJLink specification for format. + Update self.mute (audio) and self.shutter (video shutter). + 11 = Shutter closed, audio unchanged + 21 = Shutter unchanged, Audio muted + 30 = Shutter closed, audio muted + 31 = Shutter open, audio normal + + :param data: Shutter and audio status + """ + settings = {'11': {'shutter': True, 'mute': self.mute}, + '21': {'shutter': self.shutter, 'mute': True}, + '30': {'shutter': False, 'mute': False}, + '31': {'shutter': True, 'mute': True} + } + if data not in settings: + log.warning('({ip}) Invalid shutter response: {data}'.format(ip=self.ip, data=data)) + return + shutter = settings[data]['shutter'] + mute = settings[data]['mute'] + # Check if we need to update the icons + update_icons = (shutter != self.shutter) or (mute != self.mute) + self.shutter = shutter + self.mute = mute + if update_icons: + self.projectorUpdateIcons.emit() + return + + def process_clss(self, data): + """ + PJLink class that this projector supports. See PJLink specification for format. + Updates self.class. + + :param data: Class that projector supports. + """ + # bug 1550891: Projector returns non-standard class response: + # : Expected: '%1CLSS=1' + # : Received: '%1CLSS=Class 1' (Optoma) + # : Received: '%1CLSS=Version1' (BenQ) + if len(data) > 1: + log.warn("({ip}) Non-standard CLSS reply: '{data}'".format(ip=self.ip, data=data)) + # Due to stupid projectors not following standards (Optoma, BenQ comes to mind), + # AND the different responses that can be received, the semi-permanent way to + # fix the class reply is to just remove all non-digit characters. + try: + clss = re.findall('\d', data)[0] # Should only be the first match + except IndexError: + log.error("({ip}) No numbers found in class version reply '{data}' - " + "defaulting to class '1'".format(ip=self.ip, data=data)) + clss = '1' + elif not data.isdigit(): + log.error("({ip}) NAN clss version reply '{data}' - " + "defaulting to class '1'".format(ip=self.ip, data=data)) + clss = '1' + else: + clss = data + self.pjlink_class = clss + log.debug('({ip}) Setting pjlink_class for this projector to "{data}"'.format(ip=self.ip, + data=self.pjlink_class)) + return + + def process_erst(self, data): + """ + Error status. See PJLink Specifications for format. + Updates self.projector_errors + + :param data: Error status + """ + if len(data) != PJLINK_ERST_DATA['DATA_LENGTH']: + count = PJLINK_ERST_DATA['DATA_LENGTH'] + log.warn("{ip}) Invalid error status response '{data}': length != {count}".format(ip=self.ip, + data=data, + count=count)) + return + try: + datacheck = int(data) + except ValueError: + # Bad data - ignore + log.warn("({ip}) Invalid error status response '{data}'".format(ip=self.ip, data=data)) + return + if datacheck == 0: + self.projector_errors = None + # No errors + return + # We have some sort of status error, so check out what it/they are + self.projector_errors = {} + fan, lamp, temp, cover, filt, other = (data[PJLINK_ERST_DATA['FAN']], + data[PJLINK_ERST_DATA['LAMP']], + data[PJLINK_ERST_DATA['TEMP']], + data[PJLINK_ERST_DATA['COVER']], + data[PJLINK_ERST_DATA['FILTER']], + data[PJLINK_ERST_DATA['OTHER']]) + if fan != PJLINK_ERST_STATUS[E_OK]: + self.projector_errors[translate('OpenLP.ProjectorPJLink', 'Fan')] = \ + PJLINK_ERST_STATUS[fan] + if lamp != PJLINK_ERST_STATUS[E_OK]: + self.projector_errors[translate('OpenLP.ProjectorPJLink', 'Lamp')] = \ + PJLINK_ERST_STATUS[lamp] + if temp != PJLINK_ERST_STATUS[E_OK]: + self.projector_errors[translate('OpenLP.ProjectorPJLink', 'Temperature')] = \ + PJLINK_ERST_STATUS[temp] + if cover != PJLINK_ERST_STATUS[E_OK]: + self.projector_errors[translate('OpenLP.ProjectorPJLink', 'Cover')] = \ + PJLINK_ERST_STATUS[cover] + if filt != PJLINK_ERST_STATUS[E_OK]: + self.projector_errors[translate('OpenLP.ProjectorPJLink', 'Filter')] = \ + PJLINK_ERST_STATUS[filt] + if other != PJLINK_ERST_STATUS[E_OK]: + self.projector_errors[translate('OpenLP.ProjectorPJLink', 'Other')] = \ + PJLINK_ERST_STATUS[other] + return + + def process_inf1(self, data): + """ + Manufacturer name set in projector. + Updates self.manufacturer + + :param data: Projector manufacturer + """ + self.manufacturer = data + log.debug('({ip}) Setting projector manufacturer data to "{data}"'.format(ip=self.ip, data=self.manufacturer)) + return + + def process_inf2(self, data): + """ + Projector Model set in projector. + Updates self.model. + + :param data: Model name + """ + self.model = data + log.debug('({ip}) Setting projector model to "{data}"'.format(ip=self.ip, data=self.model)) + return + + def process_info(self, data): + """ + Any extra info set in projector. + Updates self.other_info. + + :param data: Projector other info + """ + self.other_info = data + log.debug('({ip}) Setting projector other_info to "{data}"'.format(ip=self.ip, data=self.other_info)) + return + + def process_inpt(self, data): + """ + Current source input selected. See PJLink specification for format. + Update self.source + + :param data: Currently selected source + """ + self.source = data + log.info('({ip}) Setting data source to "{data}"'.format(ip=self.ip, data=self.source)) + return + + def process_inst(self, data): + """ + Available source inputs. See PJLink specification for format. + Updates self.source_available + + :param data: Sources list + """ + sources = [] + check = data.split() + for source in check: + sources.append(source) + sources.sort() + self.source_available = sources + self.projectorUpdateIcons.emit() + log.debug('({ip}) Setting projector sources_available to "{data}"'.format(ip=self.ip, + data=self.source_available)) + return + + def process_lamp(self, data): + """ + Lamp(s) status. See PJLink Specifications for format. + Data may have more than 1 lamp to process. + Update self.lamp dictionary with lamp status. + + :param data: Lamp(s) status. + """ + lamps = [] + data_dict = data.split() + while data_dict: + try: + fill = {'Hours': int(data_dict[0]), 'On': False if data_dict[1] == '0' else True} + except ValueError: + # In case of invalid entry + log.warning('({ip}) process_lamp(): Invalid data "{data}"'.format(ip=self.ip, data=data)) + return + lamps.append(fill) + data_dict.pop(0) # Remove lamp hours + data_dict.pop(0) # Remove lamp on/off + self.lamp = lamps + return + + def process_name(self, data): + """ + Projector name set in projector. + Updates self.pjlink_name + + :param data: Projector name + """ + self.pjlink_name = data + log.debug('({ip}) Setting projector PJLink name to "{data}"'.format(ip=self.ip, data=self.pjlink_name)) + return + + def process_powr(self, data): + """ + Power status. See PJLink specification for format. + Update self.power with status. Update icons if change from previous setting. + + :param data: Power status + """ + log.debug('({ip}: Processing POWR command'.format(ip=self.ip)) + if data in PJLINK_POWR_STATUS: + power = PJLINK_POWR_STATUS[data] + update_icons = self.power != power + self.power = power + self.change_status(PJLINK_POWR_STATUS[data]) + if update_icons: + self.projectorUpdateIcons.emit() + # Update the input sources available + if power == S_ON: + self.send_command('INST') + else: + # Log unknown status response + log.warning('({ip}) Unknown power response: {data}'.format(ip=self.ip, data=data)) + return + + def process_rfil(self, data): + """ + Process replacement filter type + """ + if self.model_filter is None: + self.model_filter = data + else: + log.warn("({ip}) Filter model already set".format(ip=self.ip)) + log.warn("({ip}) Saved model: '{old}'".format(ip=self.ip, old=self.model_filter)) + log.warn("({ip}) New model: '{new}'".format(ip=self.ip, new=data)) + + def process_rlmp(self, data): + """ + Process replacement lamp type + """ + if self.model_lamp is None: + self.model_lamp = data + else: + log.warn("({ip}) Lamp model already set".format(ip=self.ip)) + log.warn("({ip}) Saved lamp: '{old}'".format(ip=self.ip, old=self.model_lamp)) + log.warn("({ip}) New lamp: '{new}'".format(ip=self.ip, new=data)) + + def process_snum(self, data): + """ + Serial number of projector. + + :param data: Serial number from projector. + """ + if self.serial_no is None: + log.debug("({ip}) Setting projector serial number to '{data}'".format(ip=self.ip, data=data)) + self.serial_no = data + self.db_update = False + else: + # Compare serial numbers and see if we got the same projector + if self.serial_no != data: + log.warn("({ip}) Projector serial number does not match saved serial number".format(ip=self.ip)) + log.warn("({ip}) Saved: '{old}'".format(ip=self.ip, old=self.serial_no)) + log.warn("({ip}) Received: '{new}'".format(ip=self.ip, new=data)) + log.warn("({ip}) NOT saving serial number".format(ip=self.ip)) + self.serial_no_received = data + + def process_sver(self, data): + """ + Software version of projector + """ + if self.sw_version is None: + log.debug("({ip}) Setting projector software version to '{data}'".format(ip=self.ip, data=data)) + self.sw_version = data + self.db_update = True + else: + # Compare software version and see if we got the same projector + if self.serial_no != data: + log.warn("({ip}) Projector software version does not match saved software version".format(ip=self.ip)) + log.warn("({ip}) Saved: '{old}'".format(ip=self.ip, old=self.sw_version)) + log.warn("({ip}) Received: '{new}'".format(ip=self.ip, new=data)) + log.warn("({ip}) NOT saving serial number".format(ip=self.ip)) + self.sw_version_received = data + + +class PJLink(PJLinkCommands, QtNetwork.QTcpSocket): + """ + Socket service for connecting to a PJLink-capable projector. + """ + # Signals sent by this module + changeStatus = QtCore.pyqtSignal(str, int, str) + projectorNetwork = QtCore.pyqtSignal(int) # Projector network activity + projectorStatus = QtCore.pyqtSignal(int) # Status update + projectorAuthentication = QtCore.pyqtSignal(str) # Authentication error + projectorNoAuthentication = QtCore.pyqtSignal(str) # PIN set and no authentication needed + projectorReceivedData = QtCore.pyqtSignal() # Notify when received data finished processing + projectorUpdateIcons = QtCore.pyqtSignal() # Update the status icons on toolbar + + # New commands available in PJLink Class 2 + pjlink_udp_commands = [ + 'ACKN', # Class 2 + 'ERST', # Class 1 or 2 + 'INPT', # Class 1 or 2 + 'LKUP', # Class 2 + 'POWR', # Class 1 or 2 + 'SRCH' # Class 2 + ] + + def __init__(self, port=PJLINK_PORT, *args, **kwargs): + """ + Setup for instance. + Options should be in kwargs except for port which does have a default. + + :param name: Display name + :param ip: IP address to connect to + :param port: Port to use. Default to PJLINK_PORT + :param pin: Access pin (if needed) + + Optional parameters + :param dbid: Database ID number + :param location: Location where projector is physically located + :param notes: Extra notes about the projector + :param poll_time: Time (in seconds) to poll connected projector + :param socket_timeout: Time (in seconds) to abort the connection if no response + """ + log.debug('PJlink(args={args} kwargs={kwargs})'.format(args=args, kwargs=kwargs)) + super().__init__() + self.dbid = kwargs.get('dbid') + self.ip = kwargs.get('ip') + self.location = kwargs.get('location') + self.mac_adx = kwargs.get('mac_adx') + self.name = kwargs.get('name') + self.notes = kwargs.get('notes') + self.pin = kwargs.get('pin') + self.port = port + self.db_update = False # Use to check if db needs to be updated prior to exiting + # Poll time 20 seconds unless called with something else + self.poll_time = 20000 if 'poll_time' not in kwargs else kwargs['poll_time'] * 1000 + # Timeout 5 seconds unless called with something else + self.socket_timeout = 5000 if 'socket_timeout' not in kwargs else kwargs['socket_timeout'] * 1000 + # In case we're called from somewhere that only wants information + self.no_poll = 'no_poll' in kwargs + self.i_am_running = False + self.status_connect = S_NOT_CONNECTED + self.last_command = '' + self.projector_status = S_NOT_CONNECTED + self.error_status = S_OK + # Socket information + # Add enough space to input buffer for extraneous \n \r + self.max_size = PJLINK_MAX_PACKET + 2 + self.setReadBufferSize(self.max_size) + self.reset_information() + # Set from ProjectorManager.add_projector() + self.widget = None # QListBox entry + self.timer = None # Timer that calls the poll_loop + self.send_queue = [] + self.send_busy = False + # Socket timer for some possible brain-dead projectors or network cable pulled + self.socket_timer = None def thread_started(self): """ @@ -290,28 +642,6 @@ class PJLink(QtNetwork.QTcpSocket): if self.model_lamp is None: self.send_command('RLMP', queue=True) - def process_rfil(self, data): - """ - Process replacement filter type - """ - if self.model_filter is None: - self.model_filter = data - else: - log.warn("({ip}) Filter model already set".format(ip=self.ip)) - log.warn("({ip}) Saved model: '{old}'".format(ip=self.ip, old=self.model_filter)) - log.warn("({ip}) New model: '{new}'".format(ip=self.ip, new=data)) - - def process_rlmp(self, data): - """ - Process replacement lamp type - """ - if self.model_lamp is None: - self.model_lamp = data - else: - log.warn("({ip}) Lamp model already set".format(ip=self.ip)) - log.warn("({ip}) Saved lamp: '{old}'".format(ip=self.ip, old=self.model_lamp)) - log.warn("({ip}) New lamp: '{new}'".format(ip=self.ip, new=data)) - def _get_status(self, status): """ Helper to retrieve status/error codes and convert to strings. @@ -474,6 +804,7 @@ class PJLink(QtNetwork.QTcpSocket): self.send_busy = False return read = self.readLine(self.max_size) + log.debug("({ip}) get_data(): '{buff}'".format(ip=self.ip, buff=read)) if read == -1: # No data available log.debug('({ip}) get_data(): No data available (-1)'.format(ip=self.ip)) @@ -626,317 +957,6 @@ class PJLink(QtNetwork.QTcpSocket): self.change_status(E_NETWORK, translate('OpenLP.PJLink', 'Error while sending data to projector')) - def process_command(self, cmd, data): - """ - Verifies any return error code. Calls the appropriate command handler. - - :param cmd: Command to process - :param data: Data being processed - """ - log.debug('({ip}) Processing command "{cmd}" with data "{data}"'.format(ip=self.ip, - cmd=cmd, - data=data)) - # Check if we have a future command not available yet - if cmd not in PJLINK_VALID_CMD: - log.error('({ip}) Unknown command received - ignoring'.format(ip=self.ip)) - return - elif cmd not in self.pjlink_functions: - log.warn('({ip}) Future command received - unable to process yet'.format(ip=self.ip)) - return - elif data in PJLINK_ERRORS: - # Oops - projector error - log.error('({ip}) Projector returned error "{data}"'.format(ip=self.ip, data=data)) - if data.upper() == 'ERRA': - # Authentication error - self.disconnect_from_host() - self.change_status(E_AUTHENTICATION) - log.debug('({ip}) emitting projectorAuthentication() signal'.format(ip=self.ip)) - self.projectorAuthentication.emit(self.name) - elif data.upper() == 'ERR1': - # Undefined command - self.change_status(E_UNDEFINED, '{error}: "{data}"'.format(error=ERROR_MSG[E_UNDEFINED], - data=cmd)) - elif data.upper() == 'ERR2': - # Invalid parameter - self.change_status(E_PARAMETER) - elif data.upper() == 'ERR3': - # Projector busy - self.change_status(E_UNAVAILABLE) - elif data.upper() == 'ERR4': - # Projector/display error - self.change_status(E_PROJECTOR) - self.receive_data_signal() - return - # Command succeeded - no extra information - elif data.upper() == 'OK': - log.debug('({ip}) Command returned OK'.format(ip=self.ip)) - # A command returned successfully - self.receive_data_signal() - return - # Command checks already passed - log.debug('({ip}) Calling function for {cmd}'.format(ip=self.ip, cmd=cmd)) - self.receive_data_signal() - self.pjlink_functions[cmd](data) - - def process_lamp(self, data): - """ - Lamp(s) status. See PJLink Specifications for format. - Data may have more than 1 lamp to process. - Update self.lamp dictionary with lamp status. - - :param data: Lamp(s) status. - """ - lamps = [] - data_dict = data.split() - while data_dict: - try: - fill = {'Hours': int(data_dict[0]), 'On': False if data_dict[1] == '0' else True} - except ValueError: - # In case of invalid entry - log.warning('({ip}) process_lamp(): Invalid data "{data}"'.format(ip=self.ip, data=data)) - return - lamps.append(fill) - data_dict.pop(0) # Remove lamp hours - data_dict.pop(0) # Remove lamp on/off - self.lamp = lamps - return - - def process_powr(self, data): - """ - Power status. See PJLink specification for format. - Update self.power with status. Update icons if change from previous setting. - - :param data: Power status - """ - log.debug('({ip}: Processing POWR command'.format(ip=self.ip)) - if data in PJLINK_POWR_STATUS: - power = PJLINK_POWR_STATUS[data] - update_icons = self.power != power - self.power = power - self.change_status(PJLINK_POWR_STATUS[data]) - if update_icons: - self.projectorUpdateIcons.emit() - # Update the input sources available - if power == S_ON: - self.send_command('INST') - else: - # Log unknown status response - log.warning('({ip}) Unknown power response: {data}'.format(ip=self.ip, data=data)) - return - - def process_avmt(self, data): - """ - Process shutter and speaker status. See PJLink specification for format. - Update self.mute (audio) and self.shutter (video shutter). - - :param data: Shutter and audio status - """ - shutter = self.shutter - mute = self.mute - if data == '11': - shutter = True - mute = False - elif data == '21': - shutter = False - mute = True - elif data == '30': - shutter = False - mute = False - elif data == '31': - shutter = True - mute = True - else: - log.warning('({ip}) Unknown shutter response: {data}'.format(ip=self.ip, data=data)) - update_icons = shutter != self.shutter - update_icons = update_icons or mute != self.mute - self.shutter = shutter - self.mute = mute - if update_icons: - self.projectorUpdateIcons.emit() - return - - def process_inpt(self, data): - """ - Current source input selected. See PJLink specification for format. - Update self.source - - :param data: Currently selected source - """ - self.source = data - log.info('({ip}) Setting data source to "{data}"'.format(ip=self.ip, data=self.source)) - return - - def process_clss(self, data): - """ - PJLink class that this projector supports. See PJLink specification for format. - Updates self.class. - - :param data: Class that projector supports. - """ - # bug 1550891: Projector returns non-standard class response: - # : Expected: '%1CLSS=1' - # : Received: '%1CLSS=Class 1' (Optoma) - # : Received: '%1CLSS=Version1' (BenQ) - if len(data) > 1: - log.warn("({ip}) Non-standard CLSS reply: '{data}'".format(ip=self.ip, data=data)) - # Due to stupid projectors not following standards (Optoma, BenQ comes to mind), - # AND the different responses that can be received, the semi-permanent way to - # fix the class reply is to just remove all non-digit characters. - try: - clss = re.findall('\d', data)[0] # Should only be the first match - except IndexError: - log.error("({ip}) No numbers found in class version reply - defaulting to class '1'".format(ip=self.ip)) - clss = '1' - elif not data.isdigit(): - log.error("({ip}) NAN class version reply - defaulting to class '1'".format(ip=self.ip)) - clss = '1' - else: - clss = data - self.pjlink_class = clss - log.debug('({ip}) Setting pjlink_class for this projector to "{data}"'.format(ip=self.ip, - data=self.pjlink_class)) - return - - def process_name(self, data): - """ - Projector name set in projector. - Updates self.pjlink_name - - :param data: Projector name - """ - self.pjlink_name = data - log.debug('({ip}) Setting projector PJLink name to "{data}"'.format(ip=self.ip, data=self.pjlink_name)) - return - - def process_inf1(self, data): - """ - Manufacturer name set in projector. - Updates self.manufacturer - - :param data: Projector manufacturer - """ - self.manufacturer = data - log.debug('({ip}) Setting projector manufacturer data to "{data}"'.format(ip=self.ip, data=self.manufacturer)) - return - - def process_inf2(self, data): - """ - Projector Model set in projector. - Updates self.model. - - :param data: Model name - """ - self.model = data - log.debug('({ip}) Setting projector model to "{data}"'.format(ip=self.ip, data=self.model)) - return - - def process_info(self, data): - """ - Any extra info set in projector. - Updates self.other_info. - - :param data: Projector other info - """ - self.other_info = data - log.debug('({ip}) Setting projector other_info to "{data}"'.format(ip=self.ip, data=self.other_info)) - return - - def process_inst(self, data): - """ - Available source inputs. See PJLink specification for format. - Updates self.source_available - - :param data: Sources list - """ - sources = [] - check = data.split() - for source in check: - sources.append(source) - sources.sort() - self.source_available = sources - self.projectorUpdateIcons.emit() - log.debug('({ip}) Setting projector sources_available to "{data}"'.format(ip=self.ip, - data=self.source_available)) - return - - def process_erst(self, data): - """ - Error status. See PJLink Specifications for format. - Updates self.projector_errors - - :param data: Error status - """ - try: - datacheck = int(data) - except ValueError: - # Bad data - ignore - return - if datacheck == 0: - self.projector_errors = None - else: - self.projector_errors = {} - # Fan - if data[0] != '0': - self.projector_errors[translate('OpenLP.ProjectorPJLink', 'Fan')] = \ - PJLINK_ERST_STATUS[data[0]] - # Lamp - if data[1] != '0': - self.projector_errors[translate('OpenLP.ProjectorPJLink', 'Lamp')] = \ - PJLINK_ERST_STATUS[data[1]] - # Temp - if data[2] != '0': - self.projector_errors[translate('OpenLP.ProjectorPJLink', 'Temperature')] = \ - PJLINK_ERST_STATUS[data[2]] - # Cover - if data[3] != '0': - self.projector_errors[translate('OpenLP.ProjectorPJLink', 'Cover')] = \ - PJLINK_ERST_STATUS[data[3]] - # Filter - if data[4] != '0': - self.projector_errors[translate('OpenLP.ProjectorPJLink', 'Filter')] = \ - PJLINK_ERST_STATUS[data[4]] - # Other - if data[5] != '0': - self.projector_errors[translate('OpenLP.ProjectorPJLink', 'Other')] = \ - PJLINK_ERST_STATUS[data[5]] - return - - def process_snum(self, data): - """ - Serial number of projector. - - :param data: Serial number from projector. - """ - if self.serial_no is None: - log.debug("({ip}) Setting projector serial number to '{data}'".format(ip=self.ip, data=data)) - self.serial_no = data - self.db_update = False - else: - # Compare serial numbers and see if we got the same projector - if self.serial_no != data: - log.warn("({ip}) Projector serial number does not match saved serial number".format(ip=self.ip)) - log.warn("({ip}) Saved: '{old}'".format(ip=self.ip, old=self.serial_no)) - log.warn("({ip}) Received: '{new}'".format(ip=self.ip, new=data)) - log.warn("({ip}) NOT saving serial number".format(ip=self.ip)) - self.serial_no_received = data - - def process_sver(self, data): - """ - Software version of projector - """ - if self.sw_version is None: - log.debug("({ip}) Setting projector software version to '{data}'".format(ip=self.ip, data=data)) - self.sw_version = data - self.db_update = True - else: - # Compare software version and see if we got the same projector - if self.serial_no != data: - log.warn("({ip}) Projector software version does not match saved software version".format(ip=self.ip)) - log.warn("({ip}) Saved: '{old}'".format(ip=self.ip, old=self.sw_version)) - log.warn("({ip}) Received: '{new}'".format(ip=self.ip, new=data)) - log.warn("({ip}) NOT saving serial number".format(ip=self.ip)) - self.sw_version_received = data - def connect_to_host(self): """ Initiate connection to projector. @@ -1098,11 +1118,3 @@ class PJLink(QtNetwork.QTcpSocket): self.send_busy = False self.projectorReceivedData.emit() return - - def _not_implemented(self, cmd): - """ - Log when a future PJLink command has not been implemented yet. - """ - log.warn("({ip}) Future command '{cmd}' has not been implemented yet".format(ip=self.ip, - cmd=cmd)) - return diff --git a/openlp/core/lib/projector/upgrade.py b/openlp/core/lib/projector/upgrade.py index acb3c1b0b..83cd2defb 100644 --- a/openlp/core/lib/projector/upgrade.py +++ b/openlp/core/lib/projector/upgrade.py @@ -42,7 +42,7 @@ def upgrade_1(session, metadata): """ Version 1 upgrade - old db might/might not be versioned. """ - log.debug('Skipping upgrade_1 of projector DB - not used') + log.debug('Skipping projector DB upgrade to version 1 - not used') def upgrade_2(session, metadata): @@ -60,14 +60,14 @@ def upgrade_2(session, metadata): :param session: DB session instance :param metadata: Metadata of current DB """ + log.debug('Checking projector DB upgrade to version 2') projector_table = Table('projector', metadata, autoload=True) - if 'mac_adx' not in [col.name for col in projector_table.c.values()]: - log.debug("Upgrading projector DB to version '2'") + upgrade_db = 'mac_adx' not in [col.name for col in projector_table.c.values()] + if upgrade_db: new_op = get_upgrade_op(session) new_op.add_column('projector', Column('mac_adx', types.String(18), server_default=null())) new_op.add_column('projector', Column('serial_no', types.String(30), server_default=null())) new_op.add_column('projector', Column('sw_version', types.String(30), server_default=null())) new_op.add_column('projector', Column('model_filter', types.String(30), server_default=null())) new_op.add_column('projector', Column('model_lamp', types.String(30), server_default=null())) - else: - log.warn("Skipping upgrade_2 of projector DB") + log.debug('{status} projector DB upgrade to version 2'.format(status='Updated' if upgrade_db else 'Skipping')) diff --git a/openlp/core/ui/advancedtab.py b/openlp/core/ui/advancedtab.py index bf7294ca8..58989ad8a 100644 --- a/openlp/core/ui/advancedtab.py +++ b/openlp/core/ui/advancedtab.py @@ -30,6 +30,7 @@ from PyQt5 import QtCore, QtGui, QtWidgets from openlp.core.common import AppLocation, Settings, SlideLimits, UiStrings, translate from openlp.core.common.languagemanager import format_time +from openlp.core.common.path import path_to_str from openlp.core.lib import SettingsTab, build_icon from openlp.core.ui.lib import PathEdit, PathType @@ -156,7 +157,7 @@ class AdvancedTab(SettingsTab): self.data_directory_new_label = QtWidgets.QLabel(self.data_directory_group_box) self.data_directory_new_label.setObjectName('data_directory_current_label') self.data_directory_path_edit = PathEdit(self.data_directory_group_box, path_type=PathType.Directories, - default_path=str(AppLocation.get_directory(AppLocation.DataDir))) + default_path=AppLocation.get_directory(AppLocation.DataDir)) self.data_directory_layout.addRow(self.data_directory_new_label, self.data_directory_path_edit) self.new_data_directory_has_files_label = QtWidgets.QLabel(self.data_directory_group_box) self.new_data_directory_has_files_label.setObjectName('new_data_directory_has_files_label') @@ -373,7 +374,7 @@ class AdvancedTab(SettingsTab): self.new_data_directory_has_files_label.hide() self.data_directory_cancel_button.hide() # Since data location can be changed, make sure the path is present. - self.data_directory_path_edit.path = str(AppLocation.get_data_path()) + self.data_directory_path_edit.path = AppLocation.get_data_path() # Don't allow data directory move if running portable. if settings.value('advanced/is portable'): self.data_directory_group_box.hide() @@ -497,12 +498,12 @@ class AdvancedTab(SettingsTab): 'closed.').format(path=new_data_path), defaultButton=QtWidgets.QMessageBox.No) if answer != QtWidgets.QMessageBox.Yes: - self.data_directory_path_edit.path = str(AppLocation.get_data_path()) + self.data_directory_path_edit.path = AppLocation.get_data_path() return # Check if data already exists here. - self.check_data_overwrite(new_data_path) + self.check_data_overwrite(path_to_str(new_data_path)) # Save the new location. - self.main_window.set_new_data_path(new_data_path) + self.main_window.set_new_data_path(path_to_str(new_data_path)) self.data_directory_cancel_button.show() def on_data_directory_copy_check_box_toggled(self): @@ -550,7 +551,7 @@ class AdvancedTab(SettingsTab): """ Cancel the data directory location change """ - self.data_directory_path_edit.path = str(AppLocation.get_data_path()) + self.data_directory_path_edit.path = AppLocation.get_data_path() self.data_directory_copy_check_box.setChecked(False) self.main_window.set_new_data_path(None) self.main_window.set_copy_data(False) diff --git a/openlp/core/ui/generaltab.py b/openlp/core/ui/generaltab.py index 0ed208ec5..dc084eb2b 100644 --- a/openlp/core/ui/generaltab.py +++ b/openlp/core/ui/generaltab.py @@ -23,10 +23,12 @@ The general tab of the configuration dialog. """ import logging +from pathlib import Path from PyQt5 import QtCore, QtGui, QtWidgets from openlp.core.common import Registry, Settings, UiStrings, translate, get_images_filter +from openlp.core.common.path import path_to_str, str_to_path from openlp.core.lib import SettingsTab, ScreenList from openlp.core.ui.lib import ColorButton, PathEdit @@ -172,7 +174,8 @@ class GeneralTab(SettingsTab): self.logo_layout.setObjectName('logo_layout') self.logo_file_label = QtWidgets.QLabel(self.logo_group_box) self.logo_file_label.setObjectName('logo_file_label') - self.logo_file_path_edit = PathEdit(self.logo_group_box, default_path=':/graphics/openlp-splash-screen.png') + self.logo_file_path_edit = PathEdit(self.logo_group_box, + default_path=Path(':/graphics/openlp-splash-screen.png')) self.logo_layout.addRow(self.logo_file_label, self.logo_file_path_edit) self.logo_color_label = QtWidgets.QLabel(self.logo_group_box) self.logo_color_label.setObjectName('logo_color_label') @@ -266,7 +269,7 @@ class GeneralTab(SettingsTab): self.audio_group_box.setTitle(translate('OpenLP.GeneralTab', 'Background Audio')) self.start_paused_check_box.setText(translate('OpenLP.GeneralTab', 'Start background audio paused')) self.repeat_list_check_box.setText(translate('OpenLP.GeneralTab', 'Repeat track list')) - self.logo_file_path_edit.dialog_caption = dialog_caption = translate('OpenLP.AdvancedTab', 'Select Logo File') + self.logo_file_path_edit.dialog_caption = translate('OpenLP.AdvancedTab', 'Select Logo File') self.logo_file_path_edit.filters = '{text};;{names} (*)'.format( text=get_images_filter(), names=UiStrings().AllFiles) @@ -291,7 +294,7 @@ class GeneralTab(SettingsTab): self.auto_open_check_box.setChecked(settings.value('auto open')) self.show_splash_check_box.setChecked(settings.value('show splash')) self.logo_background_color = settings.value('logo background color') - self.logo_file_path_edit.path = settings.value('logo file') + self.logo_file_path_edit.path = str_to_path(settings.value('logo file')) self.logo_hide_on_startup_check_box.setChecked(settings.value('logo hide on startup')) self.logo_color_button.color = self.logo_background_color self.check_for_updates_check_box.setChecked(settings.value('update check')) @@ -325,7 +328,7 @@ class GeneralTab(SettingsTab): settings.setValue('auto open', self.auto_open_check_box.isChecked()) settings.setValue('show splash', self.show_splash_check_box.isChecked()) settings.setValue('logo background color', self.logo_background_color) - settings.setValue('logo file', self.logo_file_path_edit.path) + settings.setValue('logo file', path_to_str(self.logo_file_path_edit.path)) settings.setValue('logo hide on startup', self.logo_hide_on_startup_check_box.isChecked()) settings.setValue('update check', self.check_for_updates_check_box.isChecked()) settings.setValue('save prompt', self.save_check_service_check_box.isChecked()) diff --git a/openlp/core/ui/lib/filedialog.py b/openlp/core/ui/lib/filedialog.py new file mode 100755 index 000000000..bd2bb5109 --- /dev/null +++ b/openlp/core/ui/lib/filedialog.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2017 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; version 2 of the License. # +# # +# This program is distributed in the hope that it will be useful, but WITHOUT # +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for # +# more details. # +# # +# You should have received a copy of the GNU General Public License along # +# with this program; if not, write to the Free Software Foundation, Inc., 59 # +# Temple Place, Suite 330, Boston, MA 02111-1307 USA # +############################################################################### +""" Patch the QFileDialog so it accepts and returns Path objects""" +from pathlib import Path + +from PyQt5 import QtWidgets + +from openlp.core.common.path import path_to_str, str_to_path +from openlp.core.lib import replace_params + + +class FileDialog(QtWidgets.QFileDialog): + @classmethod + def getExistingDirectory(cls, *args, **kwargs): + """ + Wraps `getExistingDirectory` so that it can be called with, and return Path objects + + :type parent: QtWidgets.QWidget or None + :type caption: str + :type directory: pathlib.Path + :type options: QtWidgets.QFileDialog.Options + :rtype: tuple[Path, str] + """ + args, kwargs = replace_params(args, kwargs, ((2, 'directory', path_to_str),)) + + return_value = super().getExistingDirectory(*args, **kwargs) + + # getExistingDirectory returns a str that represents the path. The string is empty if the user cancels the + # dialog. + return str_to_path(return_value) + + @classmethod + def getOpenFileName(cls, *args, **kwargs): + """ + Wraps `getOpenFileName` so that it can be called with, and return Path objects + + :type parent: QtWidgets.QWidget or None + :type caption: str + :type directory: pathlib.Path + :type filter: str + :type initialFilter: str + :type options: QtWidgets.QFileDialog.Options + :rtype: tuple[Path, str] + """ + args, kwargs = replace_params(args, kwargs, ((2, 'directory', path_to_str),)) + + file_name, selected_filter = super().getOpenFileName(*args, **kwargs) + + # getOpenFileName returns a tuple. The first item is a str that represents the path. The string is empty if + # the user cancels the dialog. + return str_to_path(file_name), selected_filter + + @classmethod + def getOpenFileNames(cls, *args, **kwargs): + """ + Wraps `getOpenFileNames` so that it can be called with, and return Path objects + + :type parent: QtWidgets.QWidget or None + :type caption: str + :type directory: pathlib.Path + :type filter: str + :type initialFilter: str + :type options: QtWidgets.QFileDialog.Options + :rtype: tuple[list[Path], str] + """ + args, kwargs = replace_params(args, kwargs, ((2, 'directory', path_to_str),)) + + file_names, selected_filter = super().getOpenFileNames(*args, **kwargs) + + # getSaveFileName returns a tuple. The first item is a list of str's that represents the path. The list is + # empty if the user cancels the dialog. + paths = [str_to_path(path) for path in file_names] + return paths, selected_filter + + @classmethod + def getSaveFileName(cls, *args, **kwargs): + """ + Wraps `getSaveFileName` so that it can be called with, and return Path objects + + :type parent: QtWidgets.QWidget or None + :type caption: str + :type directory: pathlib.Path + :type filter: str + :type initialFilter: str + :type options: QtWidgets.QFileDialog.Options + :rtype: tuple[Path or None, str] + """ + args, kwargs = replace_params(args, kwargs, ((2, 'directory', path_to_str),)) + + file_name, selected_filter = super().getSaveFileName(*args, **kwargs) + + # getSaveFileName returns a tuple. The first item represents the path as a str. The string is empty if the user + # cancels the dialog. + return str_to_path(file_name), selected_filter diff --git a/openlp/core/ui/lib/pathedit.py b/openlp/core/ui/lib/pathedit.py old mode 100755 new mode 100644 index 99d448bd6..dd6066931 --- a/openlp/core/ui/lib/pathedit.py +++ b/openlp/core/ui/lib/pathedit.py @@ -20,12 +20,14 @@ # Temple Place, Suite 330, Boston, MA 02111-1307 USA # ############################################################################### from enum import Enum -import os.path +from pathlib import Path from PyQt5 import QtCore, QtWidgets from openlp.core.common import UiStrings, translate +from openlp.core.common.path import path_to_str, str_to_path from openlp.core.lib import build_icon +from openlp.core.ui.lib.filedialog import FileDialog class PathType(Enum): @@ -38,11 +40,11 @@ class PathEdit(QtWidgets.QWidget): The :class:`~openlp.core.ui.lib.pathedit.PathEdit` class subclasses QWidget to create a custom widget for use when a file or directory needs to be selected. """ - pathChanged = QtCore.pyqtSignal(str) + pathChanged = QtCore.pyqtSignal(Path) def __init__(self, parent=None, path_type=PathType.Files, default_path=None, dialog_caption=None, show_revert=True): """ - Initalise the PathEdit widget + Initialise the PathEdit widget :param parent: The parent of the widget. This is just passed to the super method. :type parent: QWidget or None @@ -51,9 +53,9 @@ class PathEdit(QtWidgets.QWidget): :type dialog_caption: str :param default_path: The default path. This is set as the path when the revert button is clicked - :type default_path: str + :type default_path: pathlib.Path - :param show_revert: Used to determin if the 'revert button' should be visible. + :param show_revert: Used to determine if the 'revert button' should be visible. :type show_revert: bool :return: None @@ -79,7 +81,6 @@ class PathEdit(QtWidgets.QWidget): widget_layout = QtWidgets.QHBoxLayout() widget_layout.setContentsMargins(0, 0, 0, 0) self.line_edit = QtWidgets.QLineEdit(self) - self.line_edit.setText(self._path) widget_layout.addWidget(self.line_edit) self.browse_button = QtWidgets.QToolButton(self) self.browse_button.setIcon(build_icon(':/general/general_open.png')) @@ -101,7 +102,7 @@ class PathEdit(QtWidgets.QWidget): A property getter method to return the selected path. :return: The selected path - :rtype: str + :rtype: pathlib.Path """ return self._path @@ -111,11 +112,15 @@ class PathEdit(QtWidgets.QWidget): A Property setter method to set the selected path :param path: The path to set the widget to - :type path: str + :type path: pathlib.Path + + :return: None + :rtype: None """ self._path = path - self.line_edit.setText(path) - self.line_edit.setToolTip(path) + text = path_to_str(path) + self.line_edit.setText(text) + self.line_edit.setToolTip(text) @property def path_type(self): @@ -124,7 +129,7 @@ class PathEdit(QtWidgets.QWidget): selecting a file or directory. :return: The type selected - :rtype: Enum of PathEdit + :rtype: PathType """ return self._path_type @@ -133,8 +138,11 @@ class PathEdit(QtWidgets.QWidget): """ A Property setter method to set the path type - :param path: The type of path to select - :type path: Enum of PathEdit + :param path_type: The type of path to select + :type path_type: PathType + + :return: None + :rtype: None """ self._path_type = path_type self.update_button_tool_tips() @@ -142,7 +150,9 @@ class PathEdit(QtWidgets.QWidget): def update_button_tool_tips(self): """ Called to update the tooltips on the buttons. This is changing path types, and when the widget is initalised + :return: None + :rtype: None """ if self._path_type == PathType.Directories: self.browse_button.setToolTip(translate('OpenLP.PathEdit', 'Browse for directory.')) @@ -156,21 +166,21 @@ class PathEdit(QtWidgets.QWidget): A handler to handle a click on the browse button. Show the QFileDialog and process the input from the user + :return: None + :rtype: None """ caption = self.dialog_caption - path = '' + path = None if self._path_type == PathType.Directories: if not caption: caption = translate('OpenLP.PathEdit', 'Select Directory') - path = QtWidgets.QFileDialog.getExistingDirectory(self, caption, - self._path, QtWidgets.QFileDialog.ShowDirsOnly) + path = FileDialog.getExistingDirectory(self, caption, self._path, FileDialog.ShowDirsOnly) elif self._path_type == PathType.Files: if not caption: caption = self.dialog_caption = translate('OpenLP.PathEdit', 'Select File') - path, filter_used = QtWidgets.QFileDialog.getOpenFileName(self, caption, self._path, self.filters) + path, filter_used = FileDialog.getOpenFileName(self, caption, self._path, self.filters) if path: - path = os.path.normpath(path) self.on_new_path(path) def on_revert_button_clicked(self): @@ -178,16 +188,21 @@ class PathEdit(QtWidgets.QWidget): A handler to handle a click on the revert button. Set the new path to the value of the default_path instance variable. + :return: None + :rtype: None """ self.on_new_path(self.default_path) def on_line_edit_editing_finished(self): """ A handler to handle when the line edit has finished being edited. + :return: None + :rtype: None """ - self.on_new_path(self.line_edit.text()) + path = str_to_path(self.line_edit.text()) + self.on_new_path(path) def on_new_path(self, path): """ @@ -196,9 +211,10 @@ class PathEdit(QtWidgets.QWidget): Emits the pathChanged Signal :param path: The new path - :type path: str + :type path: pathlib.Path :return: None + :rtype: None """ if self._path != path: self.path = path diff --git a/openlp/core/ui/projector/manager.py b/openlp/core/ui/projector/manager.py index 7eb5451ad..0aac006e0 100644 --- a/openlp/core/ui/projector/manager.py +++ b/openlp/core/ui/projector/manager.py @@ -38,7 +38,7 @@ from openlp.core.lib.projector.constants import ERROR_MSG, ERROR_STRING, E_AUTHE E_NETWORK, E_NOT_CONNECTED, E_UNKNOWN_SOCKET_ERROR, STATUS_STRING, S_CONNECTED, S_CONNECTING, S_COOLDOWN, \ S_INITIALIZE, S_NOT_CONNECTED, S_OFF, S_ON, S_STANDBY, S_WARMUP from openlp.core.lib.projector.db import ProjectorDB -from openlp.core.lib.projector.pjlink1 import PJLink +from openlp.core.lib.projector.pjlink import PJLink from openlp.core.lib.projector.pjlink2 import PJLinkUDP from openlp.core.ui.projector.editform import ProjectorEditForm from openlp.core.ui.projector.sourceselectform import SourceSelectTabs, SourceSelectSingle diff --git a/openlp/core/ui/themeform.py b/openlp/core/ui/themeform.py index c92d5c663..bb471d0f1 100644 --- a/openlp/core/ui/themeform.py +++ b/openlp/core/ui/themeform.py @@ -28,6 +28,7 @@ import os from PyQt5 import QtCore, QtGui, QtWidgets from openlp.core.common import Registry, RegistryProperties, UiStrings, translate, get_images_filter, is_not_image_file +from openlp.core.common.path import path_to_str, str_to_path from openlp.core.lib.theme import BackgroundType, BackgroundGradientType from openlp.core.lib.ui import critical_error_message_box from openlp.core.ui import ThemeLayoutForm @@ -316,11 +317,11 @@ class ThemeForm(QtWidgets.QWizard, Ui_ThemeWizard, RegistryProperties): self.setField('background_type', 1) elif self.theme.background_type == BackgroundType.to_string(BackgroundType.Image): self.image_color_button.color = self.theme.background_border_color - self.image_path_edit.path = self.theme.background_filename + self.image_path_edit.path = str_to_path(self.theme.background_filename) self.setField('background_type', 2) elif self.theme.background_type == BackgroundType.to_string(BackgroundType.Video): self.video_color_button.color = self.theme.background_border_color - self.video_path_edit.path = self.theme.background_filename + self.video_path_edit.path = str_to_path(self.theme.background_filename) self.setField('background_type', 4) elif self.theme.background_type == BackgroundType.to_string(BackgroundType.Transparent): self.setField('background_type', 3) @@ -448,18 +449,18 @@ class ThemeForm(QtWidgets.QWizard, Ui_ThemeWizard, RegistryProperties): """ self.theme.background_end_color = color - def on_image_path_edit_path_changed(self, filename): + def on_image_path_edit_path_changed(self, file_path): """ Background Image button pushed. """ - self.theme.background_filename = filename + self.theme.background_filename = path_to_str(file_path) self.set_background_page_values() - def on_video_path_edit_path_changed(self, filename): + def on_video_path_edit_path_changed(self, file_path): """ Background video button pushed. """ - self.theme.background_filename = filename + self.theme.background_filename = path_to_str(file_path) self.set_background_page_values() def on_main_color_changed(self, color): diff --git a/openlp/core/ui/thememanager.py b/openlp/core/ui/thememanager.py index b1033646f..d7338db91 100644 --- a/openlp/core/ui/thememanager.py +++ b/openlp/core/ui/thememanager.py @@ -22,7 +22,6 @@ """ The Theme Manager manages adding, deleteing and modifying of themes. """ -import json import os import zipfile import shutil @@ -32,12 +31,14 @@ from PyQt5 import QtCore, QtGui, QtWidgets from openlp.core.common import Registry, RegistryProperties, AppLocation, Settings, OpenLPMixin, RegistryMixin, \ UiStrings, check_directory_exists, translate, is_win, get_filesystem_encoding, delete_file -from openlp.core.lib import FileDialog, ImageSource, ValidationError, get_text_file_string, build_icon, \ +from openlp.core.common.path import path_to_str, str_to_path +from openlp.core.lib import ImageSource, ValidationError, get_text_file_string, build_icon, \ check_item_selected, create_thumb, validate_thumb from openlp.core.lib.theme import Theme, BackgroundType from openlp.core.lib.ui import critical_error_message_box, create_widget_action from openlp.core.ui import FileRenameForm, ThemeForm from openlp.core.ui.lib import OpenLPToolbar +from openlp.core.ui.lib.filedialog import FileDialog from openlp.core.common.languagemanager import get_locale_key @@ -424,15 +425,17 @@ class ThemeManager(OpenLPMixin, RegistryMixin, QtWidgets.QWidget, Ui_ThemeManage those files. This process will only load version 2 themes. :param field: """ - files = FileDialog.getOpenFileNames(self, - translate('OpenLP.ThemeManager', 'Select Theme Import File'), - Settings().value(self.settings_section + '/last directory import'), - translate('OpenLP.ThemeManager', 'OpenLP Themes (*.otz)')) - self.log_info('New Themes {name}'.format(name=str(files))) - if not files: + file_paths, selected_filter = FileDialog.getOpenFileNames( + self, + translate('OpenLP.ThemeManager', 'Select Theme Import File'), + str_to_path(Settings().value(self.settings_section + '/last directory import')), + translate('OpenLP.ThemeManager', 'OpenLP Themes (*.otz)')) + self.log_info('New Themes {file_paths}'.format(file_paths=file_paths)) + if not file_paths: return self.application.set_busy_cursor() - for file_name in files: + for file_path in file_paths: + file_name = path_to_str(file_path) Settings().setValue(self.settings_section + '/last directory import', str(file_name)) self.unzip_theme(file_name, self.path) self.load_themes() diff --git a/openlp/core/ui/themewizard.py b/openlp/core/ui/themewizard.py index 65464e063..cd1748aae 100644 --- a/openlp/core/ui/themewizard.py +++ b/openlp/core/ui/themewizard.py @@ -22,6 +22,8 @@ """ The Create/Edit theme wizard """ +from pathlib import Path + from PyQt5 import QtCore, QtGui, QtWidgets from openlp.core.common import UiStrings, translate, is_macosx diff --git a/openlp/plugins/bibles/lib/importers/http.py b/openlp/plugins/bibles/lib/importers/http.py index 4ed5fa844..1616ebcf7 100644 --- a/openlp/plugins/bibles/lib/importers/http.py +++ b/openlp/plugins/bibles/lib/importers/http.py @@ -255,7 +255,7 @@ class BGExtract(RegistryProperties): chapter=chapter, version=version) soup = get_soup_for_bible_ref( - 'http://biblegateway.com/passage/?{url}'.format(url=url_params), + 'http://www.biblegateway.com/passage/?{url}'.format(url=url_params), pre_parse_regex=r'', pre_parse_substitute='') if not soup: return None @@ -284,7 +284,7 @@ class BGExtract(RegistryProperties): """ log.debug('BGExtract.get_books_from_http("{version}")'.format(version=version)) url_params = urllib.parse.urlencode({'action': 'getVersionInfo', 'vid': '{version}'.format(version=version)}) - reference_url = 'http://biblegateway.com/versions/?{url}#books'.format(url=url_params) + reference_url = 'http://www.biblegateway.com/versions/?{url}#books'.format(url=url_params) page = get_web_page(reference_url) if not page: send_error_message('download') diff --git a/openlp/plugins/presentations/lib/presentationtab.py b/openlp/plugins/presentations/lib/presentationtab.py index 2af73d369..fa804f2d3 100644 --- a/openlp/plugins/presentations/lib/presentationtab.py +++ b/openlp/plugins/presentations/lib/presentationtab.py @@ -20,10 +20,11 @@ # Temple Place, Suite 330, Boston, MA 02111-1307 USA # ############################################################################### -from PyQt5 import QtGui, QtWidgets +from PyQt5 import QtWidgets from openlp.core.common import Settings, UiStrings, translate -from openlp.core.lib import SettingsTab, build_icon +from openlp.core.common.path import path_to_str, str_to_path +from openlp.core.lib import SettingsTab from openlp.core.lib.ui import critical_error_message_box from openlp.core.ui.lib import PathEdit from openlp.plugins.presentations.lib.pdfcontroller import PdfController @@ -156,7 +157,7 @@ class PresentationTab(SettingsTab): self.program_path_edit.setEnabled(enable_pdf_program) pdf_program = Settings().value(self.settings_section + '/pdf_program') if pdf_program: - self.program_path_edit.path = pdf_program + self.program_path_edit.path = str_to_path(pdf_program) def save(self): """ @@ -192,7 +193,7 @@ class PresentationTab(SettingsTab): Settings().setValue(setting_key, self.ppt_window_check_box.checkState()) changed = True # Save pdf-settings - pdf_program = self.program_path_edit.path + pdf_program = path_to_str(self.program_path_edit.path) enable_pdf_program = self.pdf_program_check_box.checkState() # If the given program is blank disable using the program if pdf_program == '': @@ -219,12 +220,13 @@ class PresentationTab(SettingsTab): checkbox.setEnabled(controller.is_available()) self.set_controller_text(checkbox, controller) - def on_program_path_edit_path_changed(self, filename): + def on_program_path_edit_path_changed(self, new_path): """ Select the mudraw or ghostscript binary that should be used. """ - if filename: - if not PdfController.process_check_binary(filename): + new_path = path_to_str(new_path) + if new_path: + if not PdfController.process_check_binary(new_path): critical_error_message_box(UiStrings().Error, translate('PresentationPlugin.PresentationTab', 'The program is not ghostscript or mudraw which is required.')) diff --git a/openlp/plugins/songs/forms/editsongform.py b/openlp/plugins/songs/forms/editsongform.py index 61d5b1ddd..77573c5ac 100644 --- a/openlp/plugins/songs/forms/editsongform.py +++ b/openlp/plugins/songs/forms/editsongform.py @@ -28,12 +28,15 @@ import logging import re import os import shutil +from pathlib import Path from PyQt5 import QtCore, QtWidgets from openlp.core.common import Registry, RegistryProperties, AppLocation, UiStrings, check_directory_exists, translate -from openlp.core.lib import FileDialog, PluginStatus, MediaType, create_separated_list +from openlp.core.common.path import path_to_str +from openlp.core.lib import PluginStatus, MediaType, create_separated_list from openlp.core.lib.ui import set_case_insensitive_completer, critical_error_message_box, find_and_set_in_combo_box +from openlp.core.ui.lib.filedialog import FileDialog from openlp.core.common.languagemanager import get_natural_key from openlp.plugins.songs.lib import VerseType, clean_song from openlp.plugins.songs.lib.db import Book, Song, Author, AuthorType, Topic, MediaFile, SongBookEntry @@ -925,9 +928,10 @@ class EditSongForm(QtWidgets.QDialog, Ui_EditSongDialog, RegistryProperties): Loads file(s) from the filesystem. """ filters = '{text} (*)'.format(text=UiStrings().AllFiles) - file_names = FileDialog.getOpenFileNames(self, translate('SongsPlugin.EditSongForm', 'Open File(s)'), '', - filters) - for filename in file_names: + file_paths, selected_filter = FileDialog.getOpenFileNames( + self, translate('SongsPlugin.EditSongForm', 'Open File(s)'), Path(), filters) + for file_path in file_paths: + filename = path_to_str(file_path) item = QtWidgets.QListWidgetItem(os.path.split(str(filename))[1]) item.setData(QtCore.Qt.UserRole, filename) self.audio_list_widget.addItem(item) diff --git a/openlp/plugins/songs/forms/songimportform.py b/openlp/plugins/songs/forms/songimportform.py index 3547521c9..e3c0eb620 100644 --- a/openlp/plugins/songs/forms/songimportform.py +++ b/openlp/plugins/songs/forms/songimportform.py @@ -29,8 +29,9 @@ import os from PyQt5 import QtCore, QtWidgets from openlp.core.common import RegistryProperties, Settings, UiStrings, translate -from openlp.core.lib import FileDialog +from openlp.core.common.path import path_to_str, str_to_path from openlp.core.lib.ui import critical_error_message_box +from openlp.core.ui.lib.filedialog import FileDialog from openlp.core.ui.lib.wizard import OpenLPWizard, WizardStrings from openlp.plugins.songs.lib.importer import SongFormat, SongFormatSelect @@ -237,10 +238,11 @@ class SongImportForm(OpenLPWizard, RegistryProperties): if filters: filters += ';;' filters += '{text} (*)'.format(text=UiStrings().AllFiles) - file_names = FileDialog.getOpenFileNames( + file_paths, selected_filter = FileDialog.getOpenFileNames( self, title, - Settings().value(self.plugin.settings_section + '/last directory import'), filters) - if file_names: + str_to_path(Settings().value(self.plugin.settings_section + '/last directory import')), filters) + if file_paths: + file_names = [path_to_str(file_path) for file_path in file_paths] listbox.addItems(file_names) Settings().setValue(self.plugin.settings_section + '/last directory import', os.path.split(str(file_names[0]))[0]) diff --git a/openlp/plugins/songusage/forms/songusagedetailform.py b/openlp/plugins/songusage/forms/songusagedetailform.py index 172cca6b1..595d56fcd 100644 --- a/openlp/plugins/songusage/forms/songusagedetailform.py +++ b/openlp/plugins/songusage/forms/songusagedetailform.py @@ -27,6 +27,7 @@ from PyQt5 import QtCore, QtWidgets from sqlalchemy.sql import and_ from openlp.core.common import RegistryProperties, Settings, check_directory_exists, translate +from openlp.core.common.path import path_to_str, str_to_path from openlp.core.lib.ui import critical_error_message_box from openlp.plugins.songusage.lib.db import SongUsageItem from .songusagedetaildialog import Ui_SongUsageDetailDialog @@ -55,20 +56,21 @@ class SongUsageDetailForm(QtWidgets.QDialog, Ui_SongUsageDetailDialog, RegistryP """ self.from_date_calendar.setSelectedDate(Settings().value(self.plugin.settings_section + '/from date')) self.to_date_calendar.setSelectedDate(Settings().value(self.plugin.settings_section + '/to date')) - self.report_path_edit.path = Settings().value(self.plugin.settings_section + '/last directory export') + self.report_path_edit.path = str_to_path( + Settings().value(self.plugin.settings_section + '/last directory export')) def on_report_path_edit_path_changed(self, file_path): """ Triggered when the Directory selection button is clicked """ - Settings().setValue(self.plugin.settings_section + '/last directory export', file_path) + Settings().setValue(self.plugin.settings_section + '/last directory export', path_to_str(file_path)) def accept(self): """ Ok was triggered so lets save the data and run the report """ log.debug('accept') - path = self.report_path_edit.path + path = path_to_str(self.report_path_edit.path) if not path: self.main_window.error_message( translate('SongUsagePlugin.SongUsageDetailForm', 'Output Path Not Selected'), diff --git a/tests/functional/openlp_core_common/test_path.py b/tests/functional/openlp_core_common/test_path.py new file mode 100644 index 000000000..2d9490012 --- /dev/null +++ b/tests/functional/openlp_core_common/test_path.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2017 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; version 2 of the License. # +# # +# This program is distributed in the hope that it will be useful, but WITHOUT # +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for # +# more details. # +# # +# You should have received a copy of the GNU General Public License along # +# with this program; if not, write to the Free Software Foundation, Inc., 59 # +# Temple Place, Suite 330, Boston, MA 02111-1307 USA # +############################################################################### +""" +Package to test the openlp.core.common.path package. +""" +import os +from pathlib import Path +from unittest import TestCase + +from openlp.core.common.path import path_to_str, str_to_path + + +class TestPath(TestCase): + """ + Tests for the :mod:`openlp.core.common.path` module + """ + + def test_path_to_str_type_error(self): + """ + Test that `path_to_str` raises a type error when called with an invalid type + """ + # GIVEN: The `path_to_str` function + # WHEN: Calling `path_to_str` with an invalid Type + # THEN: A TypeError should have been raised + with self.assertRaises(TypeError): + path_to_str(str()) + + def test_path_to_str_none(self): + """ + Test that `path_to_str` correctly converts the path parameter when passed with None + """ + # GIVEN: The `path_to_str` function + # WHEN: Calling the `path_to_str` function with None + result = path_to_str(None) + + # THEN: `path_to_str` should return an empty string + self.assertEqual(result, '') + + def test_path_to_str_path_object(self): + """ + Test that `path_to_str` correctly converts the path parameter when passed a Path object + """ + # GIVEN: The `path_to_str` function + # WHEN: Calling the `path_to_str` function with a Path object + result = path_to_str(Path('test/path')) + + # THEN: `path_to_str` should return a string representation of the Path object + self.assertEqual(result, os.path.join('test', 'path')) + + def test_str_to_path_type_error(self): + """ + Test that `str_to_path` raises a type error when called with an invalid type + """ + # GIVEN: The `str_to_path` function + # WHEN: Calling `str_to_path` with an invalid Type + # THEN: A TypeError should have been raised + with self.assertRaises(TypeError): + str_to_path(Path()) + + def test_str_to_path_empty_str(self): + """ + Test that `str_to_path` correctly converts the string parameter when passed with and empty string + """ + # GIVEN: The `str_to_path` function + # WHEN: Calling the `str_to_path` function with None + result = str_to_path('') + + # THEN: `path_to_str` should return None + self.assertEqual(result, None) diff --git a/tests/functional/openlp_core_lib/test_file_dialog.py b/tests/functional/openlp_core_lib/test_file_dialog.py index 488900fc7..6336ce0a0 100644 --- a/tests/functional/openlp_core_lib/test_file_dialog.py +++ b/tests/functional/openlp_core_lib/test_file_dialog.py @@ -20,12 +20,10 @@ # Temple Place, Suite 330, Boston, MA 02111-1307 USA # ############################################################################### """ -Package to test the openlp.core.lib.filedialog package. +Package to test the openlp.core.ui.lib.filedialog package. """ from unittest import TestCase -from unittest.mock import MagicMock, call, patch - -from openlp.core.lib.filedialog import FileDialog +from unittest.mock import MagicMock, patch class TestFileDialog(TestCase): @@ -33,9 +31,9 @@ class TestFileDialog(TestCase): Test the functions in the :mod:`filedialog` module. """ def setUp(self): - self.os_patcher = patch('openlp.core.lib.filedialog.os') - self.qt_gui_patcher = patch('openlp.core.lib.filedialog.QtWidgets') - self.ui_strings_patcher = patch('openlp.core.lib.filedialog.UiStrings') + self.os_patcher = patch('openlp.core.ui.lib.filedialog.os') + self.qt_gui_patcher = patch('openlp.core.ui.lib.filedialog.QtWidgets') + self.ui_strings_patcher = patch('openlp.core.ui.lib.filedialog.UiStrings') self.mocked_os = self.os_patcher.start() self.mocked_qt_gui = self.qt_gui_patcher.start() self.mocked_ui_strings = self.ui_strings_patcher.start() @@ -45,52 +43,3 @@ class TestFileDialog(TestCase): self.os_patcher.stop() self.qt_gui_patcher.stop() self.ui_strings_patcher.stop() - - def test_get_open_file_names_canceled(self): - """ - Test that FileDialog.getOpenFileNames() returns and empty QStringList when QFileDialog is canceled - (returns an empty QStringList) - """ - self.mocked_os.reset_mock() - - # GIVEN: An empty QStringList as a return value from QFileDialog.getOpenFileNames - self.mocked_qt_gui.QFileDialog.getOpenFileNames.return_value = ([], []) - - # WHEN: FileDialog.getOpenFileNames is called - result = FileDialog.getOpenFileNames(self.mocked_parent) - - # THEN: The returned value should be an empty QStringList and os.path.exists should not have been called - assert not self.mocked_os.path.exists.called - self.assertEqual(result, [], - 'FileDialog.getOpenFileNames should return and empty list when QFileDialog.getOpenFileNames ' - 'is canceled') - - def test_returned_file_list(self): - """ - Test that FileDialog.getOpenFileNames handles a list of files properly when QFileList.getOpenFileNames - returns a good file name, a url encoded file name and a non-existing file - """ - self.mocked_os.rest_mock() - self.mocked_qt_gui.reset_mock() - - # GIVEN: A List of known values as a return value from QFileDialog.getOpenFileNames and a list of valid file - # names. - self.mocked_qt_gui.QFileDialog.getOpenFileNames.return_value = ([ - '/Valid File', '/url%20encoded%20file%20%231', '/non-existing'], []) - self.mocked_os.path.exists.side_effect = lambda file_name: file_name in [ - '/Valid File', '/url encoded file #1'] - self.mocked_ui_strings().FileNotFound = 'File Not Found' - self.mocked_ui_strings().FileNotFoundMessage = 'File {name} not found.\nPlease try selecting it individually.' - - # WHEN: FileDialog.getOpenFileNames is called - result = FileDialog.getOpenFileNames(self.mocked_parent) - - # THEN: os.path.exists should have been called with known args. QmessageBox.information should have been - # called. The returned result should correlate with the input. - call_list = [call('/Valid File'), call('/url%20encoded%20file%20%231'), call('/url encoded file #1'), - call('/non-existing'), call('/non-existing')] - self.mocked_os.path.exists.assert_has_calls(call_list) - self.mocked_qt_gui.QMessageBox.information.assert_called_with( - self.mocked_parent, 'File Not Found', - 'File /non-existing not found.\nPlease try selecting it individually.') - self.assertEqual(result, ['/Valid File', '/url encoded file #1'], 'The returned file list is incorrect') diff --git a/tests/functional/openlp_core_lib/test_lib.py b/tests/functional/openlp_core_lib/test_lib.py index 77f093625..5e9b52dfd 100644 --- a/tests/functional/openlp_core_lib/test_lib.py +++ b/tests/functional/openlp_core_lib/test_lib.py @@ -29,10 +29,9 @@ from unittest.mock import MagicMock, patch from PyQt5 import QtCore, QtGui -from openlp.core.lib import FormattingTags, expand_chords_for_printing -from openlp.core.lib import build_icon, check_item_selected, clean_tags, create_thumb, create_separated_list, \ - expand_tags, get_text_file_string, image_to_byte, resize_image, str_to_bool, validate_thumb, expand_chords, \ - compare_chord_lyric, find_formatting_tags +from openlp.core.lib import FormattingTags, build_icon, check_item_selected, clean_tags, compare_chord_lyric, \ + create_separated_list, create_thumb, expand_chords, expand_chords_for_printing, expand_tags, find_formatting_tags, \ + get_text_file_string, image_to_byte, replace_params, resize_image, str_to_bool, validate_thumb TEST_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'resources')) @@ -652,6 +651,38 @@ class TestLib(TestCase): mocked_os.stat.assert_any_call(thumb_path) assert result is False, 'The result should be False' + def test_replace_params_no_params(self): + """ + Test replace_params when called with and empty tuple instead of parameters to replace + """ + # GIVEN: Some test data + test_args = (1, 2) + test_kwargs = {'arg3': 3, 'arg4': 4} + test_params = tuple() + + # WHEN: Calling replace_params + result_args, result_kwargs = replace_params(test_args, test_kwargs, test_params) + + # THEN: The positional and keyword args should not have changed + self.assertEqual(test_args, result_args) + self.assertEqual(test_kwargs, result_kwargs) + + def test_replace_params_params(self): + """ + Test replace_params when given a positional and a keyword argument to change + """ + # GIVEN: Some test data + test_args = (1, 2) + test_kwargs = {'arg3': 3, 'arg4': 4} + test_params = ((1, 'arg2', str), (2, 'arg3', str)) + + # WHEN: Calling replace_params + result_args, result_kwargs = replace_params(test_args, test_kwargs, test_params) + + # THEN: The positional and keyword args should have have changed + self.assertEqual(result_args, (1, '2')) + self.assertEqual(result_kwargs, {'arg3': '3', 'arg4': 4}) + def test_resize_thumb(self): """ Test the resize_thumb() function diff --git a/tests/functional/openlp_core_lib/test_projector_constants.py b/tests/functional/openlp_core_lib/test_projector_constants.py index 019c18888..90fee1e13 100644 --- a/tests/functional/openlp_core_lib/test_projector_constants.py +++ b/tests/functional/openlp_core_lib/test_projector_constants.py @@ -22,7 +22,7 @@ """ Package to test the openlp.core.lib.projector.constants package. """ -from unittest import TestCase, skip +from unittest import TestCase class TestProjectorConstants(TestCase): @@ -40,4 +40,4 @@ class TestProjectorConstants(TestCase): from openlp.core.lib.projector.constants import PJLINK_DEFAULT_CODES # THEN: Verify dictionary was build correctly - self.assertEquals(PJLINK_DEFAULT_CODES, TEST_VIDEO_CODES, 'PJLink video strings should match') + self.assertEqual(PJLINK_DEFAULT_CODES, TEST_VIDEO_CODES, 'PJLink video strings should match') diff --git a/tests/functional/openlp_core_lib/test_projector_db.py b/tests/functional/openlp_core_lib/test_projector_db.py index d4ff4e75c..e49e75245 100644 --- a/tests/functional/openlp_core_lib/test_projector_db.py +++ b/tests/functional/openlp_core_lib/test_projector_db.py @@ -29,8 +29,8 @@ import os import shutil from tempfile import mkdtemp -from unittest import TestCase, skip -from unittest.mock import MagicMock, patch +from unittest import TestCase +from unittest.mock import patch from openlp.core.lib.projector import upgrade from openlp.core.lib.db import upgrade_db @@ -413,7 +413,7 @@ class TestProjectorDB(TestCase): Test add_projector() fail """ # GIVEN: Test entry in the database - ignore_result = self.projector.add_projector(Projector(**TEST1_DATA)) + self.projector.add_projector(Projector(**TEST1_DATA)) # WHEN: Attempt to add same projector entry results = self.projector.add_projector(Projector(**TEST1_DATA)) @@ -439,7 +439,7 @@ class TestProjectorDB(TestCase): Test update_projector() when entry not in database """ # GIVEN: Projector entry in database - ignore_result = self.projector.add_projector(Projector(**TEST1_DATA)) + self.projector.add_projector(Projector(**TEST1_DATA)) projector = Projector(**TEST2_DATA) # WHEN: Attempt to update data with a different ID diff --git a/tests/functional/openlp_core_lib/test_projector_pjlink_base.py b/tests/functional/openlp_core_lib/test_projector_pjlink_base.py new file mode 100644 index 000000000..75981b75d --- /dev/null +++ b/tests/functional/openlp_core_lib/test_projector_pjlink_base.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2015 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; version 2 of the License. # +# # +# This program is distributed in the hope that it will be useful, but WITHOUT # +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for # +# more details. # +# # +# You should have received a copy of the GNU General Public License along # +# with this program; if not, write to the Free Software Foundation, Inc., 59 # +# Temple Place, Suite 330, Boston, MA 02111-1307 USA # +############################################################################### +""" +Package to test the openlp.core.lib.projector.pjlink base package. +""" +from unittest import TestCase +from unittest.mock import call, patch, MagicMock + +from openlp.core.lib.projector.pjlink import PJLink +from openlp.core.lib.projector.constants import E_PARAMETER, ERROR_STRING, S_ON, S_CONNECTED + +from tests.resources.projector.data import TEST_PIN, TEST_SALT, TEST_CONNECT_AUTHENTICATE, TEST_HASH + +pjlink_test = PJLink(name='test', ip='127.0.0.1', pin=TEST_PIN, no_poll=True) + + +class TestPJLinkBase(TestCase): + """ + Tests for the PJLink module + """ + @patch.object(pjlink_test, 'readyRead') + @patch.object(pjlink_test, 'send_command') + @patch.object(pjlink_test, 'waitForReadyRead') + @patch('openlp.core.common.qmd5_hash') + def test_authenticated_connection_call(self, + mock_qmd5_hash, + mock_waitForReadyRead, + mock_send_command, + mock_readyRead): + """ + Ticket 92187: Fix for projector connect with PJLink authentication exception. + """ + # GIVEN: Test object + pjlink = pjlink_test + + # WHEN: Calling check_login with authentication request: + pjlink.check_login(data=TEST_CONNECT_AUTHENTICATE) + + # THEN: Should have called qmd5_hash + self.assertTrue(mock_qmd5_hash.called_with(TEST_SALT, + "Connection request should have been called with TEST_SALT")) + self.assertTrue(mock_qmd5_hash.called_with(TEST_PIN, + "Connection request should have been called with TEST_PIN")) + + @patch.object(pjlink_test, 'change_status') + def test_status_change(self, mock_change_status): + """ + Test process_command call with ERR2 (Parameter) status + """ + # GIVEN: Test object + pjlink = pjlink_test + + # WHEN: process_command is called with "ERR2" status from projector + pjlink.process_command('POWR', 'ERR2') + + # THEN: change_status should have called change_status with E_UNDEFINED + # as first parameter + mock_change_status.called_with(E_PARAMETER, + 'change_status should have been called with "{}"'.format( + ERROR_STRING[E_PARAMETER])) + + @patch.object(pjlink_test, 'send_command') + @patch.object(pjlink_test, 'waitForReadyRead') + @patch.object(pjlink_test, 'projectorAuthentication') + @patch.object(pjlink_test, 'timer') + @patch.object(pjlink_test, 'socket_timer') + def test_bug_1593882_no_pin_authenticated_connection(self, + mock_socket_timer, + mock_timer, + mock_authentication, + mock_ready_read, + mock_send_command): + """ + Test bug 1593882 no pin and authenticated request exception + """ + # GIVEN: Test object and mocks + pjlink = pjlink_test + pjlink.pin = None + mock_ready_read.return_value = True + + # WHEN: call with authentication request and pin not set + pjlink.check_login(data=TEST_CONNECT_AUTHENTICATE) + + # THEN: 'No Authentication' signal should have been sent + mock_authentication.emit.assert_called_with(pjlink.ip) + + @patch.object(pjlink_test, 'waitForReadyRead') + @patch.object(pjlink_test, 'state') + @patch.object(pjlink_test, '_send_command') + @patch.object(pjlink_test, 'timer') + @patch.object(pjlink_test, 'socket_timer') + def test_bug_1593883_pjlink_authentication(self, + mock_socket_timer, + mock_timer, + mock_send_command, + mock_state, + mock_waitForReadyRead): + """ + Test bugfix 1593883 pjlink authentication + """ + # GIVEN: Test object and data + pjlink = pjlink_test + pjlink.pin = TEST_PIN + mock_state.return_value = pjlink.ConnectedState + mock_waitForReadyRead.return_value = True + + # WHEN: Athenticated connection is called + pjlink.check_login(data=TEST_CONNECT_AUTHENTICATE) + + # THEN: send_command should have the proper authentication + self.assertEqual("{test}".format(test=mock_send_command.call_args), + "call(data='{hash}%1CLSS ?\\r')".format(hash=TEST_HASH)) + + @patch.object(pjlink_test, 'disconnect_from_host') + def test_socket_abort(self, mock_disconnect): + """ + Test PJLink.socket_abort calls disconnect_from_host + """ + # GIVEN: Test object + pjlink = pjlink_test + + # WHEN: Calling socket_abort + pjlink.socket_abort() + + # THEN: disconnect_from_host should be called + self.assertTrue(mock_disconnect.called, 'Should have called disconnect_from_host') + + def test_poll_loop_not_connected(self): + """ + Test PJLink.poll_loop not connected return + """ + # GIVEN: Test object and mocks + pjlink = pjlink_test + pjlink.state = MagicMock() + pjlink.timer = MagicMock() + pjlink.state.return_value = False + pjlink.ConnectedState = True + + # WHEN: PJLink.poll_loop called + pjlink.poll_loop() + + # THEN: poll_loop should exit without calling any other method + self.assertFalse(pjlink.timer.called, 'Should have returned without calling any other method') + + @patch.object(pjlink_test, 'send_command') + def test_poll_loop_start(self, mock_send_command): + """ + Test PJLink.poll_loop makes correct calls + """ + # GIVEN: test object and test data + pjlink = pjlink_test + pjlink.state = MagicMock() + pjlink.timer = MagicMock() + pjlink.timer.interval = MagicMock() + pjlink.timer.setInterval = MagicMock() + pjlink.timer.start = MagicMock() + pjlink.poll_time = 20 + pjlink.power = S_ON + pjlink.source_available = None + pjlink.other_info = None + pjlink.manufacturer = None + pjlink.model = None + pjlink.pjlink_name = None + pjlink.ConnectedState = S_CONNECTED + pjlink.timer.interval.return_value = 10 + pjlink.state.return_value = S_CONNECTED + call_list = [ + call('POWR', queue=True), + call('ERST', queue=True), + call('LAMP', queue=True), + call('AVMT', queue=True), + call('INPT', queue=True), + call('INST', queue=True), + call('INFO', queue=True), + call('INF1', queue=True), + call('INF2', queue=True), + call('NAME', queue=True), + ] + + # WHEN: PJLink.poll_loop is called + pjlink.poll_loop() + + # THEN: proper calls were made to retrieve projector data + # First, call to update the timer with the next interval + self.assertTrue(pjlink.timer.setInterval.called, 'Should have updated the timer') + # Next, should have called the timer to start + self.assertTrue(pjlink.timer.start.called, 'Should have started the timer') + # Finally, should have called send_command with a list of projetctor status checks + mock_send_command.assert_has_calls(call_list, 'Should have queued projector queries') diff --git a/tests/functional/openlp_core_lib/test_projector_pjlink_cmd_routing.py b/tests/functional/openlp_core_lib/test_projector_pjlink_cmd_routing.py new file mode 100644 index 000000000..0a962146a --- /dev/null +++ b/tests/functional/openlp_core_lib/test_projector_pjlink_cmd_routing.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4 + +############################################################################### +# OpenLP - Open Source Lyrics Projection # +# --------------------------------------------------------------------------- # +# Copyright (c) 2008-2015 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; version 2 of the License. # +# # +# This program is distributed in the hope that it will be useful, but WITHOUT # +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for # +# more details. # +# # +# You should have received a copy of the GNU General Public License along # +# with this program; if not, write to the Free Software Foundation, Inc., 59 # +# Temple Place, Suite 330, Boston, MA 02111-1307 USA # +############################################################################### +""" +Package to test the openlp.core.lib.projector.pjlink class command routing. +""" + +from unittest import TestCase +from unittest.mock import patch, MagicMock + +import openlp.core.lib.projector.pjlink +from openlp.core.lib.projector.pjlink import PJLink +from openlp.core.lib.projector.constants import PJLINK_ERRORS, \ + E_AUTHENTICATION, E_PARAMETER, E_PROJECTOR, E_UNAVAILABLE, E_UNDEFINED + +''' +from openlp.core.lib.projector.constants import ERROR_STRING, PJLINK_ERST_DATA, PJLINK_ERST_STATUS, \ + PJLINK_POWR_STATUS, PJLINK_VALID_CMD, E_WARN, E_ERROR, S_OFF, S_STANDBY, S_ON +''' +from tests.resources.projector.data import TEST_PIN + +pjlink_test = PJLink(name='test', ip='127.0.0.1', pin=TEST_PIN, no_poll=True) + + +class TestPJLinkRouting(TestCase): + """ + Tests for the PJLink module command routing + """ + @patch.object(openlp.core.lib.projector.pjlink, 'log') + def test_process_command_call_clss(self, mock_log): + """ + Test process_command calls proper function + """ + # GIVEN: Test object + pjlink = pjlink_test + log_text = '(127.0.0.1) Calling function for CLSS' + mock_log.reset_mock() + mock_process_clss = MagicMock() + pjlink.pjlink_functions['CLSS'] = mock_process_clss + + # WHEN: process_command is called with valid function and data + pjlink.process_command(cmd='CLSS', data='1') + + # THEN: Process method should have been called properly + mock_log.debug.assert_called_with(log_text) + mock_process_clss.assert_called_with('1') + + @patch.object(pjlink_test, 'change_status') + @patch.object(openlp.core.lib.projector.pjlink, 'log') + def test_process_command_err1(self, mock_log, mock_change_status): + """ + Test ERR1 - Undefined projector function + """ + # GIVEN: Test object + pjlink = pjlink_test + log_text = '(127.0.0.1) Projector returned error "ERR1"' + mock_change_status.reset_mock() + mock_log.reset_mock() + + # WHEN: process_command called with ERR1 + pjlink.process_command(cmd='CLSS', data=PJLINK_ERRORS[E_UNDEFINED]) + + # THEN: Error should be logged and status_change should be called + mock_change_status.assert_called_once_with(E_UNDEFINED, 'Undefined Command: "CLSS"') + mock_log.error.assert_called_with(log_text) + + @patch.object(pjlink_test, 'change_status') + @patch.object(openlp.core.lib.projector.pjlink, 'log') + def test_process_command_err2(self, mock_log, mock_change_status): + """ + Test ERR2 - Parameter Error + """ + # GIVEN: Test object + pjlink = pjlink_test + log_text = '(127.0.0.1) Projector returned error "ERR2"' + mock_change_status.reset_mock() + mock_log.reset_mock() + + # WHEN: process_command called with ERR2 + pjlink.process_command(cmd='CLSS', data=PJLINK_ERRORS[E_PARAMETER]) + + # THEN: Error should be logged and status_change should be called + mock_change_status.assert_called_once_with(E_PARAMETER) + mock_log.error.assert_called_with(log_text) + + @patch.object(pjlink_test, 'change_status') + @patch.object(openlp.core.lib.projector.pjlink, 'log') + def test_process_command_err3(self, mock_log, mock_change_status): + """ + Test ERR3 - Unavailable error + """ + # GIVEN: Test object + pjlink = pjlink_test + log_text = '(127.0.0.1) Projector returned error "ERR3"' + mock_change_status.reset_mock() + mock_log.reset_mock() + + # WHEN: process_command called with ERR3 + pjlink.process_command(cmd='CLSS', data=PJLINK_ERRORS[E_UNAVAILABLE]) + + # THEN: Error should be logged and status_change should be called + mock_change_status.assert_called_once_with(E_UNAVAILABLE) + mock_log.error.assert_called_with(log_text) + + @patch.object(pjlink_test, 'change_status') + @patch.object(openlp.core.lib.projector.pjlink, 'log') + def test_process_command_err4(self, mock_log, mock_change_status): + """ + Test ERR3 - Unavailable error + """ + # GIVEN: Test object + pjlink = pjlink_test + log_text = '(127.0.0.1) Projector returned error "ERR4"' + mock_change_status.reset_mock() + mock_log.reset_mock() + + # WHEN: process_command called with ERR3 + pjlink.process_command(cmd='CLSS', data=PJLINK_ERRORS[E_PROJECTOR]) + + # THEN: Error should be logged and status_change should be called + mock_change_status.assert_called_once_with(E_PROJECTOR) + mock_log.error.assert_called_with(log_text) + + @patch.object(pjlink_test, 'projectorAuthentication') + @patch.object(pjlink_test, 'change_status') + @patch.object(pjlink_test, 'disconnect_from_host') + @patch.object(openlp.core.lib.projector.pjlink, 'log') + def test_process_command_erra(self, mock_log, mock_disconnect, mock_change_status, mock_err_authenticate): + """ + Test ERRA - Authentication Error + """ + # GIVEN: Test object + pjlink = pjlink_test + log_text = '(127.0.0.1) Projector returned error "ERRA"' + mock_change_status.reset_mock() + mock_log.reset_mock() + + # WHEN: process_command called with ERRA + pjlink.process_command(cmd='CLSS', data=PJLINK_ERRORS[E_AUTHENTICATION]) + + # THEN: Error should be logged and several methods should be called + self.assertTrue(mock_disconnect.called, 'disconnect_from_host should have been called') + mock_change_status.assert_called_once_with(E_AUTHENTICATION) + mock_log.error.assert_called_with(log_text) + + @patch.object(openlp.core.lib.projector.pjlink, 'log') + def test_process_command_future(self, mock_log): + """ + Test command valid but no method to process yet + """ + # GIVEN: Test object + pjlink = pjlink_test + log_text = "(127.0.0.1) Unable to process command='CLSS' (Future option)" + mock_log.reset_mock() + # Remove a valid command so we can test valid command but not available yet + pjlink.pjlink_functions.pop('CLSS') + + # WHEN: process_command called with an unknown command + with patch.object(pjlink, 'pjlink_functions') as mock_functions: + pjlink.process_command(cmd='CLSS', data='DONT CARE') + + # THEN: Error should be logged and no command called + self.assertFalse(mock_functions.called, 'Should not have gotten to the end of the method') + mock_log.warn.assert_called_once_with(log_text) + + @patch.object(pjlink_test, 'pjlink_functions') + @patch.object(openlp.core.lib.projector.pjlink, 'log') + def test_process_command_invalid(self, mock_log, mock_functions): + """ + Test not a valid command + """ + # GIVEN: Test object + pjlink = pjlink_test + mock_functions.reset_mock() + mock_log.reset_mock() + + # WHEN: process_command called with an unknown command + pjlink.process_command(cmd='Unknown', data='Dont Care') + log_text = "(127.0.0.1) Ignoring command='Unknown' (Invalid/Unknown)" + + # THEN: Error should be logged and no command called + self.assertFalse(mock_functions.called, 'Should not have gotten to the end of the method') + mock_log.error.assert_called_once_with(log_text) + + @patch.object(pjlink_test, 'pjlink_functions') + @patch.object(openlp.core.lib.projector.pjlink, 'log') + def test_process_command_ok(self, mock_log, mock_functions): + """ + Test command returned success + """ + # GIVEN: Test object + pjlink = pjlink_test + mock_functions.reset_mock() + mock_log.reset_mock() + + # WHEN: process_command called with an unknown command + pjlink.process_command(cmd='CLSS', data='OK') + log_text = '(127.0.0.1) Command "CLSS" returned OK' + + # THEN: Error should be logged and no command called + self.assertFalse(mock_functions.called, 'Should not have gotten to the end of the method') + self.assertEqual(mock_log.debug.call_count, 2, 'log.debug() should have been called twice') + # Although we called it twice, only the last log entry is saved + mock_log.debug.assert_called_with(log_text) diff --git a/tests/functional/openlp_core_lib/test_projector_pjlink1.py b/tests/functional/openlp_core_lib/test_projector_pjlink_commands.py similarity index 59% rename from tests/functional/openlp_core_lib/test_projector_pjlink1.py rename to tests/functional/openlp_core_lib/test_projector_pjlink_commands.py index 969ad72e1..fec890758 100644 --- a/tests/functional/openlp_core_lib/test_projector_pjlink1.py +++ b/tests/functional/openlp_core_lib/test_projector_pjlink_commands.py @@ -20,44 +20,476 @@ # Temple Place, Suite 330, Boston, MA 02111-1307 USA # ############################################################################### """ -Package to test the openlp.core.lib.projector.pjlink1 package. +Package to test the openlp.core.lib.projector.pjlink commands package. """ from unittest import TestCase -from unittest.mock import call, patch, MagicMock +from unittest.mock import patch, MagicMock -from openlp.core.lib.projector.pjlink1 import PJLink -from openlp.core.lib.projector.constants import E_PARAMETER, ERROR_STRING, S_OFF, S_STANDBY, S_ON, \ - PJLINK_POWR_STATUS, S_CONNECTED +import openlp.core.lib.projector.pjlink +from openlp.core.lib.projector.pjlink import PJLink +from openlp.core.lib.projector.constants import ERROR_STRING, PJLINK_ERST_DATA, PJLINK_ERST_STATUS, \ + PJLINK_POWR_STATUS, E_WARN, E_ERROR, S_OFF, S_STANDBY, S_ON -from tests.resources.projector.data import TEST_PIN, TEST_SALT, TEST_CONNECT_AUTHENTICATE, TEST_HASH +from tests.resources.projector.data import TEST_PIN pjlink_test = PJLink(name='test', ip='127.0.0.1', pin=TEST_PIN, no_poll=True) +# Create a list of ERST positional data so we don't have to redo the same buildup multiple times +PJLINK_ERST_POSITIONS = [] +for pos in range(0, len(PJLINK_ERST_DATA)): + if pos in PJLINK_ERST_DATA: + PJLINK_ERST_POSITIONS.append(PJLINK_ERST_DATA[pos]) -class TestPJLink(TestCase): + +class TestPJLinkCommands(TestCase): """ Tests for the PJLink module """ - @patch.object(pjlink_test, 'readyRead') - @patch.object(pjlink_test, 'send_command') - @patch.object(pjlink_test, 'waitForReadyRead') - @patch('openlp.core.common.qmd5_hash') - def test_authenticated_connection_call(self, mock_qmd5_hash, mock_waitForReadyRead, mock_send_command, - mock_readyRead): + def test_projector_reset_information(self): """ - Ticket 92187: Fix for projector connect with PJLink authentication exception. + Test reset_information() resets all information and stops timers + """ + # GIVEN: Test object and test data + pjlink = pjlink_test + pjlink.power = S_ON + pjlink.pjlink_name = 'OPENLPTEST' + pjlink.manufacturer = 'PJLINK' + pjlink.model = '1' + pjlink.shutter = True + pjlink.mute = True + pjlink.lamp = True + pjlink.fan = True + pjlink.source_available = True + pjlink.other_info = 'ANOTHER TEST' + pjlink.send_queue = True + pjlink.send_busy = True + pjlink.timer = MagicMock() + pjlink.socket_timer = MagicMock() + + # WHEN: reset_information() is called + with patch.object(pjlink.timer, 'stop') as mock_timer: + with patch.object(pjlink.socket_timer, 'stop') as mock_socket_timer: + pjlink.reset_information() + + # THEN: All information should be reset and timers stopped + self.assertEqual(pjlink.power, S_OFF, 'Projector power should be OFF') + self.assertIsNone(pjlink.pjlink_name, 'Projector pjlink_name should be None') + self.assertIsNone(pjlink.manufacturer, 'Projector manufacturer should be None') + self.assertIsNone(pjlink.model, 'Projector model should be None') + self.assertIsNone(pjlink.shutter, 'Projector shutter should be None') + self.assertIsNone(pjlink.mute, 'Projector shuttter should be None') + self.assertIsNone(pjlink.lamp, 'Projector lamp should be None') + self.assertIsNone(pjlink.fan, 'Projector fan should be None') + self.assertIsNone(pjlink.source_available, 'Projector source_available should be None') + self.assertIsNone(pjlink.source, 'Projector source should be None') + self.assertIsNone(pjlink.other_info, 'Projector other_info should be None') + self.assertEqual(pjlink.send_queue, [], 'Projector send_queue should be an empty list') + self.assertFalse(pjlink.send_busy, 'Projector send_busy should be False') + self.assertTrue(mock_timer.called, 'Projector timer.stop() should have been called') + self.assertTrue(mock_socket_timer.called, 'Projector socket_timer.stop() should have been called') + + @patch.object(pjlink_test, 'projectorUpdateIcons') + def test_projector_process_avmt_bad_data(self, mock_UpdateIcons): + """ + Test avmt bad data fail + """ + # GIVEN: Test object + pjlink = pjlink_test + pjlink.shutter = True + pjlink.mute = True + + # WHEN: Called with an invalid setting + pjlink.process_avmt('36') + + # THEN: Shutter should be closed and mute should be True + self.assertTrue(pjlink.shutter, 'Shutter should changed') + self.assertTrue(pjlink.mute, 'Audio should not have changed') + self.assertFalse(mock_UpdateIcons.emit.called, 'Update icons should NOT have been called') + + @patch.object(pjlink_test, 'projectorUpdateIcons') + def test_projector_process_avmt_closed_muted(self, mock_UpdateIcons): + """ + Test avmt status shutter closed and mute off + """ + # GIVEN: Test object + pjlink = pjlink_test + pjlink.shutter = False + pjlink.mute = False + + # WHEN: Called with setting shutter to closed and mute on + pjlink.process_avmt('31') + + # THEN: Shutter should be closed and mute should be True + self.assertTrue(pjlink.shutter, 'Shutter should have been set to closed') + self.assertTrue(pjlink.mute, 'Audio should be muted') + self.assertTrue(mock_UpdateIcons.emit.called, 'Update icons should have been called') + + @patch.object(pjlink_test, 'projectorUpdateIcons') + def test_projector_process_avmt_shutter_closed(self, mock_UpdateIcons): + """ + Test avmt status shutter closed and audio unchanged + """ + # GIVEN: Test object + pjlink = pjlink_test + pjlink.shutter = False + pjlink.mute = True + + # WHEN: Called with setting shutter closed and mute off + pjlink.process_avmt('11') + + # THEN: Shutter should be True and mute should be False + self.assertTrue(pjlink.shutter, 'Shutter should have been set to closed') + self.assertTrue(pjlink.mute, 'Audio should not have changed') + self.assertTrue(mock_UpdateIcons.emit.called, 'Update icons should have been called') + + @patch.object(pjlink_test, 'projectorUpdateIcons') + def test_projector_process_avmt_audio_muted(self, mock_UpdateIcons): + """ + Test avmt status shutter unchanged and mute on + """ + # GIVEN: Test object + pjlink = pjlink_test + pjlink.shutter = True + pjlink.mute = False + + # WHEN: Called with setting shutter closed and mute on + pjlink.process_avmt('21') + + # THEN: Shutter should be closed and mute should be True + self.assertTrue(pjlink.shutter, 'Shutter should not have changed') + self.assertTrue(pjlink.mute, 'Audio should be off') + self.assertTrue(mock_UpdateIcons.emit.called, 'Update icons should have been called') + + @patch.object(pjlink_test, 'projectorUpdateIcons') + def test_projector_process_avmt_open_unmuted(self, mock_UpdateIcons): + """ + Test avmt status shutter open and mute off + """ + # GIVEN: Test object + pjlink = pjlink_test + pjlink.shutter = True + pjlink.mute = True + + # WHEN: Called with setting shutter to closed and mute on + pjlink.process_avmt('30') + + # THEN: Shutter should be closed and mute should be True + self.assertFalse(pjlink.shutter, 'Shutter should have been set to open') + self.assertFalse(pjlink.mute, 'Audio should be on') + self.assertTrue(mock_UpdateIcons.emit.called, 'Update icons should have been called') + + def test_projector_process_clss_one(self): + """ + Test class 1 sent from projector """ # GIVEN: Test object pjlink = pjlink_test - # WHEN: Calling check_login with authentication request: - pjlink.check_login(data=TEST_CONNECT_AUTHENTICATE) + # WHEN: Process class response + pjlink.process_clss('1') - # THEN: Should have called qmd5_hash - self.assertTrue(mock_qmd5_hash.called_with(TEST_SALT, - "Connection request should have been called with TEST_SALT")) - self.assertTrue(mock_qmd5_hash.called_with(TEST_PIN, - "Connection request should have been called with TEST_PIN")) + # THEN: Projector class should be set to 1 + self.assertEqual(pjlink.pjlink_class, '1', + 'Projector should have set class=1') + + def test_projector_process_clss_two(self): + """ + Test class 2 sent from projector + """ + # GIVEN: Test object + pjlink = pjlink_test + + # WHEN: Process class response + pjlink.process_clss('2') + + # THEN: Projector class should be set to 1 + self.assertEqual(pjlink.pjlink_class, '2', + 'Projector should have set class=2') + + def test_projector_process_clss_nonstandard_reply_optoma(self): + """ + Bugfix 1550891: CLSS request returns non-standard reply with Optoma projector + """ + # GIVEN: Test object + pjlink = pjlink_test + + # WHEN: Process non-standard reply + pjlink.process_clss('Class 1') + + # THEN: Projector class should be set with proper value + self.assertEqual(pjlink.pjlink_class, '1', + 'Non-standard class reply should have set class=1') + + def test_projector_process_clss_nonstandard_reply_benq(self): + """ + Bugfix 1550891: CLSS request returns non-standard reply with BenQ projector + """ + # GIVEN: Test object + pjlink = pjlink_test + + # WHEN: Process non-standard reply + pjlink.process_clss('Version2') + + # THEN: Projector class should be set with proper value + # NOTE: At this time BenQ is Class 1, but we're trying a different value to verify + self.assertEqual(pjlink.pjlink_class, '2', + 'Non-standard class reply should have set class=2') + + @patch.object(openlp.core.lib.projector.pjlink, 'log') + def test_projector_process_clss_invalid_nan(self, mock_log): + """ + Test CLSS reply has no class number + """ + # GIVEN: Test object + pjlink = pjlink_test + + # WHEN: Process invalid reply + pjlink.process_clss('Z') + log_warn_text = "(127.0.0.1) NAN clss version reply 'Z' - defaulting to class '1'" + + # THEN: Projector class should be set with default value + self.assertEqual(pjlink.pjlink_class, '1', + 'Non-standard class reply should have set class=1') + mock_log.error.assert_called_once_with(log_warn_text) + + @patch.object(openlp.core.lib.projector.pjlink, 'log') + def test_projector_process_clss_invalid_no_version(self, mock_log): + """ + Test CLSS reply has no class number + """ + # GIVEN: Test object + pjlink = pjlink_test + + # WHEN: Process invalid reply + pjlink.process_clss('Invalid') + log_warn_text = "(127.0.0.1) No numbers found in class version reply 'Invalid' - defaulting to class '1'" + + # THEN: Projector class should be set with default value + self.assertEqual(pjlink.pjlink_class, '1', + 'Non-standard class reply should have set class=1') + mock_log.error.assert_called_once_with(log_warn_text) + + def test_projector_process_erst_all_ok(self): + """ + Test test_projector_process_erst_all_ok + """ + # GIVEN: Test object + pjlink = pjlink_test + chk_test = PJLINK_ERST_STATUS['OK'] + chk_param = chk_test * len(PJLINK_ERST_POSITIONS) + + # WHEN: process_erst with no errors + pjlink.process_erst(chk_param) + + # THEN: PJLink instance errors should be None + self.assertIsNone(pjlink.projector_errors, 'projector_errors should have been set to None') + + @patch.object(openlp.core.lib.projector.pjlink, 'log') + def test_projector_process_erst_data_invalid_length(self, mock_log): + """ + Test test_projector_process_erst_data_invalid_length + """ + # GIVEN: Test object + pjlink = pjlink_test + pjlink.projector_errors = None + log_warn_text = "127.0.0.1) Invalid error status response '11111111': length != 6" + + # WHEN: process_erst called with invalid data (too many values + pjlink.process_erst('11111111') + + # THEN: pjlink.projector_errors should be empty and warning logged + self.assertIsNone(pjlink.projector_errors, 'There should be no errors') + self.assertTrue(mock_log.warn.called, 'Warning should have been logged') + mock_log.warn.assert_called_once_with(log_warn_text) + + @patch.object(openlp.core.lib.projector.pjlink, 'log') + def test_projector_process_erst_data_invalid_nan(self, mock_log): + """ + Test test_projector_process_erst_data_invalid_nan + """ + # GIVEN: Test object + pjlink = pjlink_test + pjlink.projector_errors = None + log_warn_text = "(127.0.0.1) Invalid error status response '1111Z1'" + + # WHEN: process_erst called with invalid data (too many values + pjlink.process_erst('1111Z1') + + # THEN: pjlink.projector_errors should be empty and warning logged + self.assertIsNone(pjlink.projector_errors, 'There should be no errors') + self.assertTrue(mock_log.warn.called, 'Warning should have been logged') + mock_log.warn.assert_called_once_with(log_warn_text) + + def test_projector_process_erst_all_warn(self): + """ + Test test_projector_process_erst_all_warn + """ + # GIVEN: Test object + pjlink = pjlink_test + chk_test = PJLINK_ERST_STATUS[E_WARN] + chk_string = ERROR_STRING[E_WARN] + chk_param = chk_test * len(PJLINK_ERST_POSITIONS) + + # WHEN: process_erst with status set to WARN + pjlink.process_erst(chk_param) + + # THEN: PJLink instance errors should match chk_value + for chk in pjlink.projector_errors: + self.assertEqual(pjlink.projector_errors[chk], chk_string, + "projector_errors['{chk}'] should have been set to {err}".format(chk=chk, + err=chk_string)) + + def test_projector_process_erst_all_error(self): + """ + Test test_projector_process_erst_all_error + """ + # GIVEN: Test object + pjlink = pjlink_test + chk_test = PJLINK_ERST_STATUS[E_ERROR] + chk_string = ERROR_STRING[E_ERROR] + chk_param = chk_test * len(PJLINK_ERST_POSITIONS) + + # WHEN: process_erst with status set to WARN + pjlink.process_erst(chk_param) + + # THEN: PJLink instance errors should match chk_value + for chk in pjlink.projector_errors: + self.assertEqual(pjlink.projector_errors[chk], chk_string, + "projector_errors['{chk}'] should have been set to {err}".format(chk=chk, + err=chk_string)) + + def test_projector_process_erst_warn_cover_only(self): + """ + Test test_projector_process_erst_warn_cover_only + """ + # GIVEN: Test object + pjlink = pjlink_test + chk_test = PJLINK_ERST_STATUS[E_WARN] + chk_string = ERROR_STRING[E_WARN] + pos = PJLINK_ERST_DATA['COVER'] + build_chk = [] + for check in range(0, len(PJLINK_ERST_POSITIONS)): + if check == pos: + build_chk.append(chk_test) + else: + build_chk.append(PJLINK_ERST_STATUS['OK']) + chk_param = ''.join(build_chk) + + # WHEN: process_erst with cover only set to WARN and all others set to OK + pjlink.process_erst(chk_param) + + # THEN: Only COVER should have an error + self.assertEqual(len(pjlink.projector_errors), 1, 'projector_errors should only have 1 error') + self.assertTrue(('Cover' in pjlink.projector_errors), 'projector_errors should have an error for "Cover"') + self.assertEqual(pjlink.projector_errors['Cover'], + chk_string, + 'projector_errors["Cover"] should have error "{err}"'.format(err=chk_string)) + + def test_projector_process_inpt(self): + """ + Test input source status shows current input + """ + # GIVEN: Test object + pjlink = pjlink_test + pjlink.source = '0' + + # WHEN: Called with input source + pjlink.process_inpt('1') + + # THEN: Input selected should reflect current input + self.assertEqual(pjlink.source, '1', 'Input source should be set to "1"') + + @patch.object(pjlink_test, 'projectorReceivedData') + def test_projector_process_lamp_single(self, mock_projectorReceivedData): + """ + Test status lamp on/off and hours + """ + # GIVEN: Test object + pjlink = pjlink_test + + # WHEN: Call process_command with lamp data + pjlink.process_command('LAMP', '22222 1') + + # THEN: Lamp should have been set with status=ON and hours=22222 + self.assertEqual(pjlink.lamp[0]['On'], True, + 'Lamp power status should have been set to TRUE') + self.assertEqual(pjlink.lamp[0]['Hours'], 22222, + 'Lamp hours should have been set to 22222') + + @patch.object(pjlink_test, 'projectorReceivedData') + def test_projector_process_lamp_multiple(self, mock_projectorReceivedData): + """ + Test status multiple lamp on/off and hours + """ + # GIVEN: Test object + pjlink = pjlink_test + + # WHEN: Call process_command with lamp data + pjlink.process_command('LAMP', '11111 1 22222 0 33333 1') + + # THEN: Lamp should have been set with proper lamp status + self.assertEqual(len(pjlink.lamp), 3, + 'Projector should have 3 lamps specified') + self.assertEqual(pjlink.lamp[0]['On'], True, + 'Lamp 1 power status should have been set to TRUE') + self.assertEqual(pjlink.lamp[0]['Hours'], 11111, + 'Lamp 1 hours should have been set to 11111') + self.assertEqual(pjlink.lamp[1]['On'], False, + 'Lamp 2 power status should have been set to FALSE') + self.assertEqual(pjlink.lamp[1]['Hours'], 22222, + 'Lamp 2 hours should have been set to 22222') + self.assertEqual(pjlink.lamp[2]['On'], True, + 'Lamp 3 power status should have been set to TRUE') + self.assertEqual(pjlink.lamp[2]['Hours'], 33333, + 'Lamp 3 hours should have been set to 33333') + + @patch.object(pjlink_test, 'projectorReceivedData') + @patch.object(pjlink_test, 'projectorUpdateIcons') + @patch.object(pjlink_test, 'send_command') + @patch.object(pjlink_test, 'change_status') + def test_projector_process_powr_on(self, + mock_change_status, + mock_send_command, + mock_UpdateIcons, + mock_ReceivedData): + """ + Test status power to ON + """ + # GIVEN: Test object and preset + pjlink = pjlink_test + pjlink.power = S_STANDBY + + # WHEN: Call process_command with turn power on command + pjlink.process_command('POWR', PJLINK_POWR_STATUS[S_ON]) + + # THEN: Power should be set to ON + self.assertEqual(pjlink.power, S_ON, 'Power should have been set to ON') + mock_send_command.assert_called_once_with('INST') + self.assertEqual(mock_UpdateIcons.emit.called, True, 'projectorUpdateIcons should have been called') + + @patch.object(pjlink_test, 'projectorReceivedData') + @patch.object(pjlink_test, 'projectorUpdateIcons') + @patch.object(pjlink_test, 'send_command') + @patch.object(pjlink_test, 'change_status') + def test_projector_process_powr_off(self, + mock_change_status, + mock_send_command, + mock_UpdateIcons, + mock_ReceivedData): + """ + Test status power to STANDBY + """ + # GIVEN: Test object and preset + pjlink = pjlink_test + pjlink.power = S_ON + + # WHEN: Call process_command with turn power on command + pjlink.process_command('POWR', PJLINK_POWR_STATUS[S_STANDBY]) + + # THEN: Power should be set to STANDBY + self.assertEqual(pjlink.power, S_STANDBY, 'Power should have been set to STANDBY') + self.assertEqual(mock_send_command.called, False, 'send_command should not have been called') + self.assertEqual(mock_UpdateIcons.emit.called, True, 'projectorUpdateIcons should have been called') def test_projector_process_rfil_save(self): """ @@ -150,419 +582,3 @@ class TestPJLink(TestCase): # THEN: Serial number should be set self.assertNotEquals(pjlink.serial_no, test_number, 'Projector serial number should NOT have been set') - - def test_projector_clss_one(self): - """ - Test class 1 sent from projector - """ - # GIVEN: Test object - pjlink = pjlink_test - - # WHEN: Process class response - pjlink.process_clss('1') - - # THEN: Projector class should be set to 1 - self.assertEqual(pjlink.pjlink_class, '1', - 'Projector should have returned class=1') - - def test_projector_clss_two(self): - """ - Test class 2 sent from projector - """ - # GIVEN: Test object - pjlink = pjlink_test - - # WHEN: Process class response - pjlink.process_clss('2') - - # THEN: Projector class should be set to 1 - self.assertEqual(pjlink.pjlink_class, '2', - 'Projector should have returned class=2') - - def test_bug_1550891_non_standard_class_reply(self): - """ - Bugfix 1550891: CLSS request returns non-standard reply - """ - # GIVEN: Test object - pjlink = pjlink_test - - # WHEN: Process non-standard reply - pjlink.process_clss('Class 1') - - # THEN: Projector class should be set with proper value - self.assertEqual(pjlink.pjlink_class, '1', - 'Non-standard class reply should have set class=1') - - @patch.object(pjlink_test, 'change_status') - def test_status_change(self, mock_change_status): - """ - Test process_command call with ERR2 (Parameter) status - """ - # GIVEN: Test object - pjlink = pjlink_test - - # WHEN: process_command is called with "ERR2" status from projector - pjlink.process_command('POWR', 'ERR2') - - # THEN: change_status should have called change_status with E_UNDEFINED - # as first parameter - mock_change_status.called_with(E_PARAMETER, - 'change_status should have been called with "{}"'.format( - ERROR_STRING[E_PARAMETER])) - - @patch.object(pjlink_test, 'process_inpt') - def test_projector_return_ok(self, mock_process_inpt): - """ - Test projector calls process_inpt command when process_command is called with INPT option - """ - # GIVEN: Test object - pjlink = pjlink_test - - # WHEN: process_command is called with INST command and 31 input: - pjlink.process_command('INPT', '31') - - # THEN: process_inpt method should have been called with 31 - mock_process_inpt.called_with('31', - "process_inpt should have been called with 31") - - @patch.object(pjlink_test, 'projectorReceivedData') - def test_projector_process_lamp(self, mock_projectorReceivedData): - """ - Test status lamp on/off and hours - """ - # GIVEN: Test object - pjlink = pjlink_test - - # WHEN: Call process_command with lamp data - pjlink.process_command('LAMP', '22222 1') - - # THEN: Lamp should have been set with status=ON and hours=22222 - self.assertEqual(pjlink.lamp[0]['On'], True, - 'Lamp power status should have been set to TRUE') - self.assertEqual(pjlink.lamp[0]['Hours'], 22222, - 'Lamp hours should have been set to 22222') - - @patch.object(pjlink_test, 'projectorReceivedData') - def test_projector_process_multiple_lamp(self, mock_projectorReceivedData): - """ - Test status multiple lamp on/off and hours - """ - # GIVEN: Test object - pjlink = pjlink_test - - # WHEN: Call process_command with lamp data - pjlink.process_command('LAMP', '11111 1 22222 0 33333 1') - - # THEN: Lamp should have been set with proper lamp status - self.assertEqual(len(pjlink.lamp), 3, - 'Projector should have 3 lamps specified') - self.assertEqual(pjlink.lamp[0]['On'], True, - 'Lamp 1 power status should have been set to TRUE') - self.assertEqual(pjlink.lamp[0]['Hours'], 11111, - 'Lamp 1 hours should have been set to 11111') - self.assertEqual(pjlink.lamp[1]['On'], False, - 'Lamp 2 power status should have been set to FALSE') - self.assertEqual(pjlink.lamp[1]['Hours'], 22222, - 'Lamp 2 hours should have been set to 22222') - self.assertEqual(pjlink.lamp[2]['On'], True, - 'Lamp 3 power status should have been set to TRUE') - self.assertEqual(pjlink.lamp[2]['Hours'], 33333, - 'Lamp 3 hours should have been set to 33333') - - @patch.object(pjlink_test, 'projectorReceivedData') - @patch.object(pjlink_test, 'projectorUpdateIcons') - @patch.object(pjlink_test, 'send_command') - @patch.object(pjlink_test, 'change_status') - def test_projector_process_power_on(self, mock_change_status, - mock_send_command, - mock_UpdateIcons, - mock_ReceivedData): - """ - Test status power to ON - """ - # GIVEN: Test object and preset - pjlink = pjlink_test - pjlink.power = S_STANDBY - - # WHEN: Call process_command with turn power on command - pjlink.process_command('POWR', PJLINK_POWR_STATUS[S_ON]) - - # THEN: Power should be set to ON - self.assertEqual(pjlink.power, S_ON, 'Power should have been set to ON') - mock_send_command.assert_called_once_with('INST') - self.assertEqual(mock_UpdateIcons.emit.called, True, 'projectorUpdateIcons should have been called') - - @patch.object(pjlink_test, 'projectorReceivedData') - @patch.object(pjlink_test, 'projectorUpdateIcons') - @patch.object(pjlink_test, 'send_command') - @patch.object(pjlink_test, 'change_status') - def test_projector_process_power_off(self, mock_change_status, - mock_send_command, - mock_UpdateIcons, - mock_ReceivedData): - """ - Test status power to STANDBY - """ - # GIVEN: Test object and preset - pjlink = pjlink_test - pjlink.power = S_ON - - # WHEN: Call process_command with turn power on command - pjlink.process_command('POWR', PJLINK_POWR_STATUS[S_STANDBY]) - - # THEN: Power should be set to STANDBY - self.assertEqual(pjlink.power, S_STANDBY, 'Power should have been set to STANDBY') - self.assertEqual(mock_send_command.called, False, 'send_command should not have been called') - self.assertEqual(mock_UpdateIcons.emit.called, True, 'projectorUpdateIcons should have been called') - - @patch.object(pjlink_test, 'projectorUpdateIcons') - def test_projector_process_avmt_closed_unmuted(self, mock_projectorReceivedData): - """ - Test avmt status shutter closed and audio muted - """ - # GIVEN: Test object - pjlink = pjlink_test - pjlink.shutter = False - pjlink.mute = True - - # WHEN: Called with setting shutter closed and mute off - pjlink.process_avmt('11') - - # THEN: Shutter should be True and mute should be False - self.assertTrue(pjlink.shutter, 'Shutter should have been set to closed') - self.assertFalse(pjlink.mute, 'Audio should be off') - - @patch.object(pjlink_test, 'projectorUpdateIcons') - def test_projector_process_avmt_open_muted(self, mock_projectorReceivedData): - """ - Test avmt status shutter open and mute on - """ - # GIVEN: Test object - pjlink = pjlink_test - pjlink.shutter = True - pjlink.mute = False - - # WHEN: Called with setting shutter closed and mute on - pjlink.process_avmt('21') - - # THEN: Shutter should be closed and mute should be True - self.assertFalse(pjlink.shutter, 'Shutter should have been set to closed') - self.assertTrue(pjlink.mute, 'Audio should be off') - - @patch.object(pjlink_test, 'projectorUpdateIcons') - def test_projector_process_avmt_open_unmuted(self, mock_projectorReceivedData): - """ - Test avmt status shutter open and mute off off - """ - # GIVEN: Test object - pjlink = pjlink_test - pjlink.shutter = True - pjlink.mute = True - - # WHEN: Called with setting shutter to closed and mute on - pjlink.process_avmt('30') - - # THEN: Shutter should be closed and mute should be True - self.assertFalse(pjlink.shutter, 'Shutter should have been set to open') - self.assertFalse(pjlink.mute, 'Audio should be on') - - @patch.object(pjlink_test, 'projectorUpdateIcons') - def test_projector_process_avmt_closed_muted(self, mock_projectorReceivedData): - """ - Test avmt status shutter closed and mute off - """ - # GIVEN: Test object - pjlink = pjlink_test - pjlink.shutter = False - pjlink.mute = False - - # WHEN: Called with setting shutter to closed and mute on - pjlink.process_avmt('31') - - # THEN: Shutter should be closed and mute should be True - self.assertTrue(pjlink.shutter, 'Shutter should have been set to closed') - self.assertTrue(pjlink.mute, 'Audio should be on') - - def test_projector_process_input(self): - """ - Test input source status shows current input - """ - # GIVEN: Test object - pjlink = pjlink_test - pjlink.source = '0' - - # WHEN: Called with input source - pjlink.process_inpt('1') - - # THEN: Input selected should reflect current input - self.assertEqual(pjlink.source, '1', 'Input source should be set to "1"') - - def test_projector_reset_information(self): - """ - Test reset_information() resets all information and stops timers - """ - # GIVEN: Test object and test data - pjlink = pjlink_test - pjlink.power = S_ON - pjlink.pjlink_name = 'OPENLPTEST' - pjlink.manufacturer = 'PJLINK' - pjlink.model = '1' - pjlink.shutter = True - pjlink.mute = True - pjlink.lamp = True - pjlink.fan = True - pjlink.source_available = True - pjlink.other_info = 'ANOTHER TEST' - pjlink.send_queue = True - pjlink.send_busy = True - pjlink.timer = MagicMock() - pjlink.socket_timer = MagicMock() - - # WHEN: reset_information() is called - with patch.object(pjlink.timer, 'stop') as mock_timer: - with patch.object(pjlink.socket_timer, 'stop') as mock_socket_timer: - pjlink.reset_information() - - # THEN: All information should be reset and timers stopped - self.assertEqual(pjlink.power, S_OFF, 'Projector power should be OFF') - self.assertIsNone(pjlink.pjlink_name, 'Projector pjlink_name should be None') - self.assertIsNone(pjlink.manufacturer, 'Projector manufacturer should be None') - self.assertIsNone(pjlink.model, 'Projector model should be None') - self.assertIsNone(pjlink.shutter, 'Projector shutter should be None') - self.assertIsNone(pjlink.mute, 'Projector shuttter should be None') - self.assertIsNone(pjlink.lamp, 'Projector lamp should be None') - self.assertIsNone(pjlink.fan, 'Projector fan should be None') - self.assertIsNone(pjlink.source_available, 'Projector source_available should be None') - self.assertIsNone(pjlink.source, 'Projector source should be None') - self.assertIsNone(pjlink.other_info, 'Projector other_info should be None') - self.assertEqual(pjlink.send_queue, [], 'Projector send_queue should be an empty list') - self.assertFalse(pjlink.send_busy, 'Projector send_busy should be False') - self.assertTrue(mock_timer.called, 'Projector timer.stop() should have been called') - self.assertTrue(mock_socket_timer.called, 'Projector socket_timer.stop() should have been called') - - @patch.object(pjlink_test, 'send_command') - @patch.object(pjlink_test, 'waitForReadyRead') - @patch.object(pjlink_test, 'projectorAuthentication') - @patch.object(pjlink_test, 'timer') - @patch.object(pjlink_test, 'socket_timer') - def test_bug_1593882_no_pin_authenticated_connection(self, mock_socket_timer, - mock_timer, - mock_authentication, - mock_ready_read, - mock_send_command): - """ - Test bug 1593882 no pin and authenticated request exception - """ - # GIVEN: Test object and mocks - pjlink = pjlink_test - pjlink.pin = None - mock_ready_read.return_value = True - - # WHEN: call with authentication request and pin not set - pjlink.check_login(data=TEST_CONNECT_AUTHENTICATE) - - # THEN: 'No Authentication' signal should have been sent - mock_authentication.emit.assert_called_with(pjlink.ip) - - @patch.object(pjlink_test, 'waitForReadyRead') - @patch.object(pjlink_test, 'state') - @patch.object(pjlink_test, '_send_command') - @patch.object(pjlink_test, 'timer') - @patch.object(pjlink_test, 'socket_timer') - def test_bug_1593883_pjlink_authentication(self, mock_socket_timer, - mock_timer, - mock_send_command, - mock_state, - mock_waitForReadyRead): - """ - Test bugfix 1593883 pjlink authentication - """ - # GIVEN: Test object and data - pjlink = pjlink_test - pjlink.pin = TEST_PIN - mock_state.return_value = pjlink.ConnectedState - mock_waitForReadyRead.return_value = True - - # WHEN: Athenticated connection is called - pjlink.check_login(data=TEST_CONNECT_AUTHENTICATE) - - # THEN: send_command should have the proper authentication - self.assertEqual("{test}".format(test=mock_send_command.call_args), - "call(data='{hash}%1CLSS ?\\r')".format(hash=TEST_HASH)) - - @patch.object(pjlink_test, 'disconnect_from_host') - def socket_abort_test(self, mock_disconnect): - """ - Test PJLink.socket_abort calls disconnect_from_host - """ - # GIVEN: Test object - pjlink = pjlink_test - - # WHEN: Calling socket_abort - pjlink.socket_abort() - - # THEN: disconnect_from_host should be called - self.assertTrue(mock_disconnect.called, 'Should have called disconnect_from_host') - - def poll_loop_not_connected_test(self): - """ - Test PJLink.poll_loop not connected return - """ - # GIVEN: Test object and mocks - pjlink = pjlink_test - pjlink.state = MagicMock() - pjlink.timer = MagicMock() - pjlink.state.return_value = False - pjlink.ConnectedState = True - - # WHEN: PJLink.poll_loop called - pjlink.poll_loop() - - # THEN: poll_loop should exit without calling any other method - self.assertFalse(pjlink.timer.called, 'Should have returned without calling any other method') - - @patch.object(pjlink_test, 'send_command') - def poll_loop_start_test(self, mock_send_command): - """ - Test PJLink.poll_loop makes correct calls - """ - # GIVEN: test object and test data - pjlink = pjlink_test - pjlink.state = MagicMock() - pjlink.timer = MagicMock() - pjlink.timer.interval = MagicMock() - pjlink.timer.setInterval = MagicMock() - pjlink.timer.start = MagicMock() - pjlink.poll_time = 20 - pjlink.power = S_ON - pjlink.source_available = None - pjlink.other_info = None - pjlink.manufacturer = None - pjlink.model = None - pjlink.pjlink_name = None - pjlink.ConnectedState = S_CONNECTED - pjlink.timer.interval.return_value = 10 - pjlink.state.return_value = S_CONNECTED - call_list = [ - call('POWR', queue=True), - call('ERST', queue=True), - call('LAMP', queue=True), - call('AVMT', queue=True), - call('INPT', queue=True), - call('INST', queue=True), - call('INFO', queue=True), - call('INF1', queue=True), - call('INF2', queue=True), - call('NAME', queue=True), - ] - - # WHEN: PJLink.poll_loop is called - pjlink.poll_loop() - - # THEN: proper calls were made to retrieve projector data - # First, call to update the timer with the next interval - self.assertTrue(pjlink.timer.setInterval.called, 'Should have updated the timer') - # Next, should have called the timer to start - self.assertTrue(pjlink.timer.start.called, 'Should have started the timer') - # Finally, should have called send_command with a list of projetctor status checks - mock_send_command.assert_has_calls(call_list, 'Should have queued projector queries') diff --git a/tests/functional/openlp_core_ui/test_themeform.py b/tests/functional/openlp_core_ui/test_themeform.py index f19255fd4..97152feed 100644 --- a/tests/functional/openlp_core_ui/test_themeform.py +++ b/tests/functional/openlp_core_ui/test_themeform.py @@ -22,6 +22,7 @@ """ Package to test the openlp.core.ui.themeform package. """ +from pathlib import Path from unittest import TestCase from unittest.mock import MagicMock, patch @@ -45,7 +46,7 @@ class TestThemeManager(TestCase): self.instance.theme = MagicMock() # WHEN: `on_image_path_edit_path_changed` is clicked - self.instance.on_image_path_edit_path_changed('/new/pat.h') + self.instance.on_image_path_edit_path_changed(Path('/', 'new', 'pat.h')) # THEN: The theme background file should be set and `set_background_page_values` should have been called self.assertEqual(self.instance.theme.background_filename, '/new/pat.h') diff --git a/tests/functional/openlp_core_ui_lib/test_color_button.py b/tests/functional/openlp_core_ui_lib/test_colorbutton.py similarity index 100% rename from tests/functional/openlp_core_ui_lib/test_color_button.py rename to tests/functional/openlp_core_ui_lib/test_colorbutton.py diff --git a/tests/functional/openlp_core_ui_lib/test_filedialog.py b/tests/functional/openlp_core_ui_lib/test_filedialog.py new file mode 100755 index 000000000..6ec045d47 --- /dev/null +++ b/tests/functional/openlp_core_ui_lib/test_filedialog.py @@ -0,0 +1,188 @@ +import os +from unittest import TestCase +from unittest.mock import patch +from pathlib import Path + +from PyQt5 import QtWidgets + +from openlp.core.ui.lib.filedialog import FileDialog + + +class TestFileDialogPatches(TestCase): + """ + Tests for the :mod:`openlp.core.ui.lib.filedialogpatches` module + """ + + def test_file_dialog(self): + """ + Test that the :class:`FileDialog` instantiates correctly + """ + # GIVEN: The FileDialog class + # WHEN: Creating an instance + instance = FileDialog() + + # THEN: The instance should be an instance of QFileDialog + self.assertIsInstance(instance, QtWidgets.QFileDialog) + + def test_get_existing_directory_user_abort(self): + """ + Test that `getExistingDirectory` handles the case when the user cancels the dialog + """ + # GIVEN: FileDialog with a mocked QDialog.getExistingDirectory method + # WHEN: Calling FileDialog.getExistingDirectory and the user cancels the dialog returns a empty string + with patch('PyQt5.QtWidgets.QFileDialog.getExistingDirectory', return_value=''): + result = FileDialog.getExistingDirectory() + + # THEN: The result should be None + self.assertEqual(result, None) + + def test_get_existing_directory_user_accepts(self): + """ + Test that `getExistingDirectory` handles the case when the user accepts the dialog + """ + # GIVEN: FileDialog with a mocked QDialog.getExistingDirectory method + # WHEN: Calling FileDialog.getExistingDirectory, the user chooses a file and accepts the dialog (it returns a + # string pointing to the directory) + with patch('PyQt5.QtWidgets.QFileDialog.getExistingDirectory', return_value=os.path.join('test', 'dir')): + result = FileDialog.getExistingDirectory() + + # THEN: getExistingDirectory() should return a Path object pointing to the chosen file + self.assertEqual(result, Path('test', 'dir')) + + def test_get_existing_directory_param_order(self): + """ + Test that `getExistingDirectory` passes the parameters to `QFileDialog.getExistingDirectory` in the correct + order + """ + # GIVEN: FileDialog + with patch('openlp.core.ui.lib.filedialog.QtWidgets.QFileDialog.getExistingDirectory', return_value='') \ + as mocked_get_existing_directory: + + # WHEN: Calling the getExistingDirectory method with all parameters set + FileDialog.getExistingDirectory('Parent', 'Caption', Path('test', 'dir'), 'Options') + + # THEN: The `QFileDialog.getExistingDirectory` should have been called with the parameters in the correct + # order + mocked_get_existing_directory.assert_called_once_with('Parent', 'Caption', os.path.join('test', 'dir'), + 'Options') + + def test_get_open_file_name_user_abort(self): + """ + Test that `getOpenFileName` handles the case when the user cancels the dialog + """ + # GIVEN: FileDialog with a mocked QDialog.getOpenFileName method + # WHEN: Calling FileDialog.getOpenFileName and the user cancels the dialog (it returns a tuple with the first + # value set as an empty string) + with patch('PyQt5.QtWidgets.QFileDialog.getOpenFileName', return_value=('', '')): + result = FileDialog.getOpenFileName() + + # THEN: First value should be None + self.assertEqual(result[0], None) + + def test_get_open_file_name_user_accepts(self): + """ + Test that `getOpenFileName` handles the case when the user accepts the dialog + """ + # GIVEN: FileDialog with a mocked QDialog.getOpenFileName method + # WHEN: Calling FileDialog.getOpenFileName, the user chooses a file and accepts the dialog (it returns a + # tuple with the first value set as an string pointing to the file) + with patch('PyQt5.QtWidgets.QFileDialog.getOpenFileName', + return_value=(os.path.join('test', 'chosen.file'), '')): + result = FileDialog.getOpenFileName() + + # THEN: getOpenFileName() should return a tuple with the first value set to a Path object pointing to the + # chosen file + self.assertEqual(result[0], Path('test', 'chosen.file')) + + def test_get_open_file_name_selected_filter(self): + """ + Test that `getOpenFileName` does not modify the selectedFilter as returned by `QFileDialog.getOpenFileName` + """ + # GIVEN: FileDialog with a mocked QDialog.get_save_file_name method + # WHEN: Calling FileDialog.getOpenFileName, and `QFileDialog.getOpenFileName` returns a known `selectedFilter` + with patch('PyQt5.QtWidgets.QFileDialog.getOpenFileName', return_value=('', 'selected filter')): + result = FileDialog.getOpenFileName() + + # THEN: getOpenFileName() should return a tuple with the second value set to a the selected filter + self.assertEqual(result[1], 'selected filter') + + def test_get_open_file_names_user_abort(self): + """ + Test that `getOpenFileNames` handles the case when the user cancels the dialog + """ + # GIVEN: FileDialog with a mocked QDialog.getOpenFileNames method + # WHEN: Calling FileDialog.getOpenFileNames and the user cancels the dialog (it returns a tuple with the first + # value set as an empty list) + with patch('PyQt5.QtWidgets.QFileDialog.getOpenFileNames', return_value=([], '')): + result = FileDialog.getOpenFileNames() + + # THEN: First value should be an empty list + self.assertEqual(result[0], []) + + def test_get_open_file_names_user_accepts(self): + """ + Test that `getOpenFileNames` handles the case when the user accepts the dialog + """ + # GIVEN: FileDialog with a mocked QDialog.getOpenFileNames method + # WHEN: Calling FileDialog.getOpenFileNames, the user chooses some files and accepts the dialog (it returns a + # tuple with the first value set as a list of strings pointing to the file) + with patch('PyQt5.QtWidgets.QFileDialog.getOpenFileNames', + return_value=([os.path.join('test', 'chosen.file1'), os.path.join('test', 'chosen.file2')], '')): + result = FileDialog.getOpenFileNames() + + # THEN: getOpenFileNames() should return a tuple with the first value set to a list of Path objects pointing + # to the chosen file + self.assertEqual(result[0], [Path('test', 'chosen.file1'), Path('test', 'chosen.file2')]) + + def test_get_open_file_names_selected_filter(self): + """ + Test that `getOpenFileNames` does not modify the selectedFilter as returned by `QFileDialog.getOpenFileNames` + """ + # GIVEN: FileDialog with a mocked QDialog.getOpenFileNames method + # WHEN: Calling FileDialog.getOpenFileNames, and `QFileDialog.getOpenFileNames` returns a known + # `selectedFilter` + with patch('PyQt5.QtWidgets.QFileDialog.getOpenFileNames', return_value=([], 'selected filter')): + result = FileDialog.getOpenFileNames() + + # THEN: getOpenFileNames() should return a tuple with the second value set to a the selected filter + self.assertEqual(result[1], 'selected filter') + + def test_get_save_file_name_user_abort(self): + """ + Test that `getSaveFileName` handles the case when the user cancels the dialog + """ + # GIVEN: FileDialog with a mocked QDialog.get_save_file_name method + # WHEN: Calling FileDialog.getSaveFileName and the user cancels the dialog (it returns a tuple with the first + # value set as an empty string) + with patch('PyQt5.QtWidgets.QFileDialog.getSaveFileName', return_value=('', '')): + result = FileDialog.getSaveFileName() + + # THEN: First value should be None + self.assertEqual(result[0], None) + + def test_get_save_file_name_user_accepts(self): + """ + Test that `getSaveFileName` handles the case when the user accepts the dialog + """ + # GIVEN: FileDialog with a mocked QDialog.getSaveFileName method + # WHEN: Calling FileDialog.getSaveFileName, the user chooses a file and accepts the dialog (it returns a + # tuple with the first value set as an string pointing to the file) + with patch('PyQt5.QtWidgets.QFileDialog.getSaveFileName', + return_value=(os.path.join('test', 'chosen.file'), '')): + result = FileDialog.getSaveFileName() + + # THEN: getSaveFileName() should return a tuple with the first value set to a Path object pointing to the + # chosen file + self.assertEqual(result[0], Path('test', 'chosen.file')) + + def test_get_save_file_name_selected_filter(self): + """ + Test that `getSaveFileName` does not modify the selectedFilter as returned by `QFileDialog.getSaveFileName` + """ + # GIVEN: FileDialog with a mocked QDialog.get_save_file_name method + # WHEN: Calling FileDialog.getSaveFileName, and `QFileDialog.getSaveFileName` returns a known `selectedFilter` + with patch('PyQt5.QtWidgets.QFileDialog.getSaveFileName', return_value=('', 'selected filter')): + result = FileDialog.getSaveFileName() + + # THEN: getSaveFileName() should return a tuple with the second value set to a the selected filter + self.assertEqual(result[1], 'selected filter') diff --git a/tests/functional/openlp_core_ui_lib/test_path_edit.py b/tests/functional/openlp_core_ui_lib/test_pathedit.py similarity index 77% rename from tests/functional/openlp_core_ui_lib/test_path_edit.py rename to tests/functional/openlp_core_ui_lib/test_pathedit.py index 111951622..9ef4dff4b 100755 --- a/tests/functional/openlp_core_ui_lib/test_path_edit.py +++ b/tests/functional/openlp_core_ui_lib/test_pathedit.py @@ -22,12 +22,13 @@ """ This module contains tests for the openlp.core.ui.lib.pathedit module """ +import os +from pathlib import Path from unittest import TestCase - -from PyQt5 import QtWidgets +from unittest.mock import MagicMock, PropertyMock, patch from openlp.core.ui.lib import PathEdit, PathType -from unittest.mock import MagicMock, PropertyMock, patch +from openlp.core.ui.lib.filedialog import FileDialog class TestPathEdit(TestCase): @@ -43,11 +44,11 @@ class TestPathEdit(TestCase): Test the `path` property getter. """ # GIVEN: An instance of PathEdit with the `_path` instance variable set - self.widget._path = 'getter/test/pat.h' + self.widget._path = Path('getter', 'test', 'pat.h') # WHEN: Reading the `path` property # THEN: The value that we set should be returned - self.assertEqual(self.widget.path, 'getter/test/pat.h') + self.assertEqual(self.widget.path, Path('getter', 'test', 'pat.h')) def test_path_setter(self): """ @@ -57,13 +58,13 @@ class TestPathEdit(TestCase): self.widget.line_edit = MagicMock() # WHEN: Writing to the `path` property - self.widget.path = 'setter/test/pat.h' + self.widget.path = Path('setter', 'test', 'pat.h') # THEN: The `_path` instance variable should be set with the test data. The `line_edit` text and tooltip # should have also been set. - self.assertEqual(self.widget._path, 'setter/test/pat.h') - self.widget.line_edit.setToolTip.assert_called_once_with('setter/test/pat.h') - self.widget.line_edit.setText.assert_called_once_with('setter/test/pat.h') + self.assertEqual(self.widget._path, Path('setter', 'test', 'pat.h')) + self.widget.line_edit.setToolTip.assert_called_once_with(os.path.join('setter', 'test', 'pat.h')) + self.widget.line_edit.setText.assert_called_once_with(os.path.join('setter', 'test', 'pat.h')) def test_path_type_getter(self): """ @@ -125,22 +126,20 @@ class TestPathEdit(TestCase): """ # GIVEN: An instance of PathEdit with the `path_type` set to `Directories` and a mocked # QFileDialog.getExistingDirectory - with patch('openlp.core.ui.lib.pathedit.QtWidgets.QFileDialog.getExistingDirectory', return_value='') as \ + with patch('openlp.core.ui.lib.pathedit.FileDialog.getExistingDirectory', return_value=None) as \ mocked_get_existing_directory, \ - patch('openlp.core.ui.lib.pathedit.QtWidgets.QFileDialog.getOpenFileName') as \ - mocked_get_open_file_name, \ - patch('openlp.core.ui.lib.pathedit.os.path.normpath') as mocked_normpath: + patch('openlp.core.ui.lib.pathedit.FileDialog.getOpenFileName') as mocked_get_open_file_name: self.widget._path_type = PathType.Directories - self.widget._path = 'test/path/' + self.widget._path = Path('test', 'path') # WHEN: Calling on_browse_button_clicked self.widget.on_browse_button_clicked() # THEN: The FileDialog.getExistingDirectory should have been called with the default caption - mocked_get_existing_directory.assert_called_once_with(self.widget, 'Select Directory', 'test/path/', - QtWidgets.QFileDialog.ShowDirsOnly) + mocked_get_existing_directory.assert_called_once_with(self.widget, 'Select Directory', + Path('test', 'path'), + FileDialog.ShowDirsOnly) self.assertFalse(mocked_get_open_file_name.called) - self.assertFalse(mocked_normpath.called) def test_on_browse_button_clicked_directory_custom_caption(self): """ @@ -149,45 +148,40 @@ class TestPathEdit(TestCase): """ # GIVEN: An instance of PathEdit with the `path_type` set to `Directories` and a mocked # QFileDialog.getExistingDirectory with `default_caption` set. - with patch('openlp.core.ui.lib.pathedit.QtWidgets.QFileDialog.getExistingDirectory', return_value='') as \ + with patch('openlp.core.ui.lib.pathedit.FileDialog.getExistingDirectory', return_value=None) as \ mocked_get_existing_directory, \ - patch('openlp.core.ui.lib.pathedit.QtWidgets.QFileDialog.getOpenFileName') as \ - mocked_get_open_file_name, \ - patch('openlp.core.ui.lib.pathedit.os.path.normpath') as mocked_normpath: + patch('openlp.core.ui.lib.pathedit.FileDialog.getOpenFileName') as mocked_get_open_file_name: self.widget._path_type = PathType.Directories - self.widget._path = 'test/path/' + self.widget._path = Path('test', 'path') self.widget.dialog_caption = 'Directory Caption' # WHEN: Calling on_browse_button_clicked self.widget.on_browse_button_clicked() # THEN: The FileDialog.getExistingDirectory should have been called with the custom caption - mocked_get_existing_directory.assert_called_once_with(self.widget, 'Directory Caption', 'test/path/', - QtWidgets.QFileDialog.ShowDirsOnly) + mocked_get_existing_directory.assert_called_once_with(self.widget, 'Directory Caption', + Path('test', 'path'), + FileDialog.ShowDirsOnly) self.assertFalse(mocked_get_open_file_name.called) - self.assertFalse(mocked_normpath.called) def test_on_browse_button_clicked_file(self): """ Test the `browse_button` `clicked` handler on_browse_button_clicked when the `path_type` is set to Files. """ # GIVEN: An instance of PathEdit with the `path_type` set to `Files` and a mocked QFileDialog.getOpenFileName - with patch('openlp.core.ui.lib.pathedit.QtWidgets.QFileDialog.getExistingDirectory') as \ - mocked_get_existing_directory, \ - patch('openlp.core.ui.lib.pathedit.QtWidgets.QFileDialog.getOpenFileName', return_value=('', '')) as \ - mocked_get_open_file_name, \ - patch('openlp.core.ui.lib.pathedit.os.path.normpath') as mocked_normpath: + with patch('openlp.core.ui.lib.pathedit.FileDialog.getExistingDirectory') as mocked_get_existing_directory, \ + patch('openlp.core.ui.lib.pathedit.FileDialog.getOpenFileName', return_value=(None, '')) as \ + mocked_get_open_file_name: self.widget._path_type = PathType.Files - self.widget._path = 'test/pat.h' + self.widget._path = Path('test', 'pat.h') # WHEN: Calling on_browse_button_clicked self.widget.on_browse_button_clicked() # THEN: The FileDialog.getOpenFileName should have been called with the default caption - mocked_get_open_file_name.assert_called_once_with(self.widget, 'Select File', 'test/pat.h', + mocked_get_open_file_name.assert_called_once_with(self.widget, 'Select File', Path('test', 'pat.h'), self.widget.filters) self.assertFalse(mocked_get_existing_directory.called) - self.assertFalse(mocked_normpath.called) def test_on_browse_button_clicked_file_custom_caption(self): """ @@ -196,23 +190,20 @@ class TestPathEdit(TestCase): """ # GIVEN: An instance of PathEdit with the `path_type` set to `Files` and a mocked QFileDialog.getOpenFileName # with `default_caption` set. - with patch('openlp.core.ui.lib.pathedit.QtWidgets.QFileDialog.getExistingDirectory') as \ - mocked_get_existing_directory, \ - patch('openlp.core.ui.lib.pathedit.QtWidgets.QFileDialog.getOpenFileName', return_value=('', '')) as \ - mocked_get_open_file_name, \ - patch('openlp.core.ui.lib.pathedit.os.path.normpath') as mocked_normpath: + with patch('openlp.core.ui.lib.pathedit.FileDialog.getExistingDirectory') as mocked_get_existing_directory, \ + patch('openlp.core.ui.lib.pathedit.FileDialog.getOpenFileName', return_value=(None, '')) as \ + mocked_get_open_file_name: self.widget._path_type = PathType.Files - self.widget._path = 'test/pat.h' + self.widget._path = Path('test', 'pat.h') self.widget.dialog_caption = 'File Caption' # WHEN: Calling on_browse_button_clicked self.widget.on_browse_button_clicked() # THEN: The FileDialog.getOpenFileName should have been called with the custom caption - mocked_get_open_file_name.assert_called_once_with(self.widget, 'File Caption', 'test/pat.h', + mocked_get_open_file_name.assert_called_once_with(self.widget, 'File Caption', Path('test', 'pat.h'), self.widget.filters) self.assertFalse(mocked_get_existing_directory.called) - self.assertFalse(mocked_normpath.called) def test_on_browse_button_clicked_user_cancels(self): """ @@ -221,16 +212,14 @@ class TestPathEdit(TestCase): """ # GIVEN: An instance of PathEdit with a mocked QFileDialog.getOpenFileName which returns an empty str for the # file path. - with patch('openlp.core.ui.lib.pathedit.QtWidgets.QFileDialog.getOpenFileName', return_value=('', '')) as \ - mocked_get_open_file_name, \ - patch('openlp.core.ui.lib.pathedit.os.path.normpath') as mocked_normpath: + with patch('openlp.core.ui.lib.pathedit.FileDialog.getOpenFileName', return_value=(None, '')) as \ + mocked_get_open_file_name: # WHEN: Calling on_browse_button_clicked self.widget.on_browse_button_clicked() # THEN: normpath should not have been called self.assertTrue(mocked_get_open_file_name.called) - self.assertFalse(mocked_normpath.called) def test_on_browse_button_clicked_user_accepts(self): """ @@ -239,9 +228,8 @@ class TestPathEdit(TestCase): """ # GIVEN: An instance of PathEdit with a mocked QFileDialog.getOpenFileName which returns a str for the file # path. - with patch('openlp.core.ui.lib.pathedit.QtWidgets.QFileDialog.getOpenFileName', - return_value=('/test/pat.h', '')) as mocked_get_open_file_name, \ - patch('openlp.core.ui.lib.pathedit.os.path.normpath') as mocked_normpath, \ + with patch('openlp.core.ui.lib.pathedit.FileDialog.getOpenFileName', + return_value=(Path('test', 'pat.h'), '')) as mocked_get_open_file_name, \ patch.object(self.widget, 'on_new_path'): # WHEN: Calling on_browse_button_clicked @@ -249,7 +237,6 @@ class TestPathEdit(TestCase): # THEN: normpath and `on_new_path` should have been called self.assertTrue(mocked_get_open_file_name.called) - mocked_normpath.assert_called_once_with('/test/pat.h') self.assertTrue(self.widget.on_new_path.called) def test_on_revert_button_clicked(self): @@ -258,13 +245,13 @@ class TestPathEdit(TestCase): """ # GIVEN: An instance of PathEdit with a mocked `on_new_path`, and the `default_path` set. with patch.object(self.widget, 'on_new_path') as mocked_on_new_path: - self.widget.default_path = '/default/pat.h' + self.widget.default_path = Path('default', 'pat.h') # WHEN: Calling `on_revert_button_clicked` self.widget.on_revert_button_clicked() # THEN: on_new_path should have been called with the default path - mocked_on_new_path.assert_called_once_with('/default/pat.h') + mocked_on_new_path.assert_called_once_with(Path('default', 'pat.h')) def test_on_line_edit_editing_finished(self): """ @@ -272,13 +259,13 @@ class TestPathEdit(TestCase): """ # GIVEN: An instance of PathEdit with a mocked `line_edit` and `on_new_path`. with patch.object(self.widget, 'on_new_path') as mocked_on_new_path: - self.widget.line_edit = MagicMock(**{'text.return_value': '/test/pat.h'}) + self.widget.line_edit = MagicMock(**{'text.return_value': 'test/pat.h'}) # WHEN: Calling `on_line_edit_editing_finished` self.widget.on_line_edit_editing_finished() # THEN: on_new_path should have been called with the path enetered in `line_edit` - mocked_on_new_path.assert_called_once_with('/test/pat.h') + mocked_on_new_path.assert_called_once_with(Path('test', 'pat.h')) def test_on_new_path_no_change(self): """ @@ -286,11 +273,11 @@ class TestPathEdit(TestCase): """ # GIVEN: An instance of PathEdit with a test path and mocked `pathChanged` signal with patch('openlp.core.ui.lib.pathedit.PathEdit.path', new_callable=PropertyMock): - self.widget._path = '/old/test/pat.h' + self.widget._path = Path('/old', 'test', 'pat.h') self.widget.pathChanged = MagicMock() # WHEN: Calling `on_new_path` with the same path as the existing path - self.widget.on_new_path('/old/test/pat.h') + self.widget.on_new_path(Path('/old', 'test', 'pat.h')) # THEN: The `pathChanged` signal should not be emitted self.assertFalse(self.widget.pathChanged.emit.called) @@ -301,11 +288,11 @@ class TestPathEdit(TestCase): """ # GIVEN: An instance of PathEdit with a test path and mocked `pathChanged` signal with patch('openlp.core.ui.lib.pathedit.PathEdit.path', new_callable=PropertyMock): - self.widget._path = '/old/test/pat.h' + self.widget._path = Path('/old', 'test', 'pat.h') self.widget.pathChanged = MagicMock() # WHEN: Calling `on_new_path` with the a new path - self.widget.on_new_path('/new/test/pat.h') + self.widget.on_new_path(Path('/new', 'test', 'pat.h')) # THEN: The `pathChanged` signal should be emitted - self.widget.pathChanged.emit.assert_called_once_with('/new/test/pat.h') + self.widget.pathChanged.emit.assert_called_once_with(Path('/new', 'test', 'pat.h'))