diff --git a/src/fixate/__init__.py b/src/fixate/__init__.py index a59531f5..c8463c65 100644 --- a/src/fixate/__init__.py +++ b/src/fixate/__init__.py @@ -30,6 +30,25 @@ generate_relay_matrix_pin_list as generate_relay_matrix_pin_list, ) +from fixate._ui import ( + Validator as Validator, + UiColour as UiColour, + user_input as user_input, + user_input_float as user_input_float, + user_serial as user_serial, + user_yes_no as user_yes_no, + user_info as user_info, + user_info_important as user_info_important, + user_ok as user_ok, + user_action as user_action, + user_image as user_image, + user_image_clear as user_image_clear, + user_gif as user_gif, + user_post_sequence_info_pass as user_post_sequence_info_pass, + user_post_sequence_info_fail as user_post_sequence_info_fail, + user_post_sequence_info as user_post_sequence_info, +) + from fixate.main import run_main_program as run __version__ = "0.6.3" diff --git a/src/fixate/_ui.py b/src/fixate/_ui.py new file mode 100644 index 00000000..b6d70f8b --- /dev/null +++ b/src/fixate/_ui.py @@ -0,0 +1,341 @@ +""" +This module provides the user interface for fixate. It is agnostic of the +actual implementation of the UI and provides a standard set of functions used +to obtain or display information from/to the user. +""" + +from typing import Callable, Any +from queue import Queue, Empty +from enum import StrEnum +import time +from pubsub import pub + +# going to honour the post sequence info display from `ui.py` +from fixate.config import RESOURCES +from fixate.core.exceptions import UserInputError +from collections import OrderedDict + + +class Validator: + """ + Defines a validator object that can be used to validate user input. + """ + + def __init__(self, func: Callable[[Any], bool], errror_msg: str = "Invalid input"): + """ + Args: + func (function): The function to validate the input + error_msg (str): The message to display if the input is invalid + """ + self.func = func + self.error_msg = errror_msg + + def __call__(self, resp: Any) -> bool: + """ + Args: + resp (Any): The response to validate + + Returns: + bool: True if the response is valid, False otherwise + """ + return self.func(resp) + + def __str__(self) -> str: + return self.error_msg + + +class UiColour(StrEnum): + RED = "red" + GREEN = "green" + BLUE = "blue" + YELLOW = "yellow" + WHITE = "white" + BLACK = "black" + CYAN = "cyan" + MAGENTA = "magenta" + GREY = "grey" + + +def _user_request_input(msg: str): + q = Queue() + pub.sendMessage("UI_block_start") + pub.sendMessage("UI_req_input", msg=msg, q=q) + resp = q.get() + pub.sendMessage("UI_block_end") + return resp + + +def user_input(msg: str) -> str: + """ + A blocking function that asks the UI to ask the user for raw input. + + Args: + msg (str): A message that will be shown to the user + + Returns: + resp (str): The user response from the UI + """ + return _user_request_input(msg) + + +def user_input_float(msg: str, attempts: int = 5) -> float: + """ + A blocking function that asks the UI to ask the user for input and converts the response to a float. + + Args: + msg (str): A message that will be shown to the user + attempts (int): Number of attempts the user has to get the input right + + Returns: + resp (float): The converted user response from the UI + + Raises: + UserInputError: If the user fails to enter a number after the specified number of attempts + """ + resp = _user_request_input(msg) + for _ in range(attempts): + try: + return float(resp) + except ValueError: + pub.sendMessage( + "UI_display_important", msg="Invalid input, please enter a number" + ) + resp = _user_request_input(msg) + raise UserInputError("User failed to enter a number") + + +def _ten_digit_int_serial(serial: str) -> bool: + return len(serial) == 10 and serial.isdigit() + + +_ten_digit_int_serial_v = Validator( + _ten_digit_int_serial, "Please enter a 10 digit serial number" +) + + +def user_serial( + msg: str, + validator: Validator = _ten_digit_int_serial_v, + return_type: int | str = int, + attempts: int = 5, +) -> Any: + """ + A blocking function that asks the UI to ask the user for a serial number. + + Args: + msg (str): A message that will be shown to the user + validator (Validator): An optional function to validate the serial number, + defaults to checking for a 10 digit integer. This function shall return + True if the serial number is valid, False otherwise. + return_type (int | str): The type to return the serial number as, defaults to int + + Returns: + resp (str): The user response from the UI + """ + resp = _user_request_input(msg) + for _ in range(attempts): + if validator(resp): + return return_type(resp) + pub.sendMessage("UI_display_important", msg=f"Invalid input: {validator}") + resp = _user_request_input(msg) + raise UserInputError("User failed to enter the correct format serial number") + + +def _user_req_choices(msg: str, choices: tuple): + # TODO - do we really need this check since this is a private function and any callers should be calling correctly + if len(choices) < 2: + raise ValueError(f"Requires at least two choices to work, {choices} provided") + q = Queue() + pub.sendMessage("UI_block_start") + pub.sendMessage("UI_req_choices", msg=msg, q=q, choices=choices) + resp = q.get() + pub.sendMessage("UI_block_end") + return resp + + +def _choice_from_response(choices: tuple, resp: str) -> str | bool: + for choice in choices: + if resp.startswith(choice[0]): + return choice + return False + + +def _user_choices(msg: str, choices: tuple, attempts: int = 5) -> str: + resp = _user_req_choices(msg, choices).upper() + for _ in range(attempts): + choice = _choice_from_response(choices, resp) + if choice: + return choice + pub.sendMessage( + "UI_display_important", + msg="Invalid input, please enter a valid choice; first letter or full word", + ) + resp = _user_req_choices(msg, choices).upper() + raise UserInputError("User failed to enter a valid response") + + +def user_yes_no(msg: str, attempts: int = 1) -> str: + """ + A blocking function that asks the UI to ask the user for a yes or no response. + + Args: + msg (str): A message that will be shown to the user + + Returns: + resp (str): 'YES' or 'NO' + """ + CHOICES = ("YES", "NO") + return _user_choices(msg, CHOICES, attempts) + + +def _user_retry_abort_fail(msg: str, attempts: int = 1) -> str: + CHOICES = ("RETRY", "ABORT", "FAIL") + return _user_choices(msg, CHOICES, attempts) + + +def user_info(msg: str): + pub.sendMessage("UI_display", msg=msg) + + +def user_info_important( + msg: str, colour: UiColour = UiColour.RED, bg_colour: UiColour = UiColour.WHITE +): + pub.sendMessage("UI_display_important", msg=msg, colour=colour, bg_colour=bg_colour) + + +def user_ok(msg: str): + """ + A blocking function that asks the UI to display a message and waits for the user to press OK/Enter. + """ + pub.sendMessage("UI_block_start") + pub.sendMessage("UI_req", msg=msg) + pub.sendMessage("UI_block_end") + + +def user_action(msg: str, action_monitor: Callable[[], bool]) -> bool: + """ + Prompts the user to complete an action. + Actively monitors the target infinitely until the event is detected or a user fail event occurs + + Args: + msg (str): Message to display to the user + action_monitor (function): A function that will be called until the user action is cancelled. The function + should return False if it hasn't completed. If the action is finished return True. + + Returns: + bool: True if the action is finished, False otherwise + """ + # UserActionCallback is used to handle the cancellation of the action either by the user or by the action itself + class UserActionCallback: + def __init__(self): + # The UI implementation must provide queue.Queue object. We + # monitor that object. If it is non-empty, we get the message + # in the q and cancel the target call. + self.user_cancel_queue = None + + # In the case that the target exists the user action instead + # of the user, we need to tell the UI to do any clean up that + # might be required. (e.g. return GUI buttons to the default state + # Does not need to be implemented by the UI. + # Function takes no args and should return None. + self.target_finished_callback = lambda: None + + def set_user_cancel_queue(self, cancel_queue): + self.user_cancel_queue = cancel_queue + + def set_target_finished_callback(self, callback): + self.target_finished_callback = callback + + callback_obj = UserActionCallback() + pub.sendMessage("UI_action", msg=msg, callback_obj=callback_obj) + try: + while True: + try: + callback_obj.user_cancel_queue.get_nowait() + return False + except Empty: + pass + + if action_monitor(): + return True + + # Yield control for other threads but don't slow down target + time.sleep(0) + finally: + # No matter what, if we exit, we want to reset the UI + callback_obj.target_finished_callback() + + +def user_image(path: str): + """ + Display an image to the user + + Args: + path (str): The path to the image file. The underlying library does not take a pathlib.Path object. + """ + pub.sendMessage("UI_image", path=path) + + +def user_image_clear(): + """ + Clear the image canvas + """ + pub.sendMessage("UI_image_clear") + + +def user_gif(path: str): + """ + Display a gif to the user + + Args: + path (str): The path to the gif file. The underlying library does not take a pathlib.Path object. + """ + pub.sendMessage("UI_gif", path=path) + + +def _user_post_sequence_info(msg: str, status: str): + if "_post_sequence_info" not in RESOURCES["SEQUENCER"].context_data: + RESOURCES["SEQUENCER"].context_data["_post_sequence_info"] = OrderedDict() + RESOURCES["SEQUENCER"].context_data["_post_sequence_info"][msg] = status + + +def user_post_sequence_info_pass(msg: str): + """ + Adds information to be displayed to the user at the end if the sequence passes + This information will be displayed in the order that this function is called. + Multiple calls with the same message will result in the previous being overwritten. + + This is useful for providing a summary of the sequence to the user at the end. + + Args: + msg (str): The message to display. + """ + _user_post_sequence_info(msg, "PASSED") + + +def user_post_sequence_info_fail(msg: str): + """ + Adds information to be displayed to the user at the end if the sequence fails. + This information will be displayed in the order that this function is called. + Multiple calls with the same message will result in the previous being overwritten. + + This is useful for providing a summary of the sequence to the user at the end. + + Args: + msg (str): The message to display. + """ + _user_post_sequence_info(msg, "FAILED") + + +def user_post_sequence_info(msg: str): + """ + Adds information to be displayed to the user at the end of the sequence. + This information will be displayed in the order that this function is called. + Multiple calls with the same message will result in the previous being overwritten. + + This is useful for providing a summary of the sequence to the user at the end. + + Args: + msg (str): The message to display. + """ + _user_post_sequence_info(msg, "ALL") diff --git a/src/fixate/core/ui.py b/src/fixate/core/ui.py index 602f38cc..da279639 100644 --- a/src/fixate/core/ui.py +++ b/src/fixate/core/ui.py @@ -3,9 +3,10 @@ """ import time from queue import Queue, Empty +from collections import OrderedDict from pubsub import pub from fixate.config import RESOURCES -from collections import OrderedDict +from fixate.core.exceptions import UserInputError USER_YES_NO = ("YES", "NO") USER_RETRY_ABORT_FAIL = ("RETRY", "ABORT", "FAIL") @@ -27,12 +28,26 @@ def _user_req_input(msg, target=None, attempts=5, **kwargs): """ q = Queue() pub.sendMessage("UI_block_start") - pub.sendMessage( - "UI_req_input", msg=msg, q=q, target=target, attempts=attempts, kwargs=kwargs - ) - resp = q.get() + for _ in range(attempts): + pub.sendMessage("UI_req_input", msg=msg, q=q) + resp = q.get() + if target is None: + return resp + ret_val = target(resp, **kwargs) + # check for not False so that if ret_val is an empty string or 0 it not result in error + # see Issue #213: https://github.com/PyFixate/Fixate/issues/213 + if ret_val is not False: + pub.sendMessage("UI_block_end") + # live up to the original contract + return ("Result", ret_val) + + # if we get here we have exhausted attempts + # so return the same response that the original implementation did pub.sendMessage("UI_block_end") - return resp + return ( + "Exception", + UserInputError("Maximum number of attempts {} reached".format(attempts)), + ) def _user_req_choices(msg, choices, target=None, attempts=5): @@ -55,17 +70,24 @@ def _user_req_choices(msg, choices, target=None, attempts=5): ) q = Queue() pub.sendMessage("UI_block_start") - pub.sendMessage( - "UI_req_choices", - msg=msg, - q=q, - choices=choices, - target=target, - attempts=attempts, - ) - resp = q.get() + for _ in range(attempts): + pub.sendMessage("UI_req_choices", msg=msg, q=q, choices=choices) + resp = q.get() + ret_val = target(resp, choices) + # check for not False so that if ret_val is an empty string or 0 it not result in error + # see Issue #213: https://github.com/PyFixate/Fixate/issues/213 + if ret_val is not False: + pub.sendMessage("UI_block_end") + # live up to the original contract + return ("Result", ret_val) + + # if we get here we have exhausted attempts + # so return the same response that the original implementation did pub.sendMessage("UI_block_end") - return resp + return ( + "Exception", + UserInputError("Maximum number of attempts {} reached".format(attempts)), + ) def user_info(msg): @@ -93,7 +115,7 @@ def _float_validate(entry): try: return float(entry) except ValueError: - user_info("Please enter a number") + user_info("Please enter a number!") return False @@ -168,12 +190,11 @@ def user_ok(msg): :param msg: A message that will be shown to the user """ - q = Queue() pub.sendMessage("UI_block_start") - pub.sendMessage("UI_req", msg=msg, q=q) - resp = q.get() + pub.sendMessage("UI_req", msg=msg) pub.sendMessage("UI_block_end") - return resp + # Moved the logic to the UI layer, both cmd and gui used to put None on the queue. + return None def user_image(path): diff --git a/src/fixate/main.py b/src/fixate/main.py index d5db95a8..a13928a0 100644 --- a/src/fixate/main.py +++ b/src/fixate/main.py @@ -11,7 +11,8 @@ from pathlib import Path import fixate.config from fixate.core.exceptions import SequenceAbort -from fixate.core.ui import user_info_important, user_serial, user_ok +from fixate.core.ui import user_info_important, user_ok +from fixate import user_serial from fixate.reporting import register_csv, unregister_csv from fixate.ui_cmdline import register_cmd_line, unregister_cmd_line import fixate.sequencer @@ -276,13 +277,10 @@ def ui_run(self): if self.args.serial_number is None: serial_response = user_serial("Please enter serial number") if serial_response == "ABORT_FORCE": + # ABORT_FORCE will only ever come from the GUI. return ReturnCodes.ABORTED - elif serial_response[0] == "Exception": - # Should be tuple: ("Exception", ) - raise serial_response[1] else: - # Should be tuple: ("Result", serial_number) - serial_number = serial_response[1] + serial_number = serial_response self.sequencer.context_data["serial_number"] = serial_number else: serial_number = self.args.serial_number diff --git a/src/fixate/sequencer.py b/src/fixate/sequencer.py index 9e4b3436..416f7899 100644 --- a/src/fixate/sequencer.py +++ b/src/fixate/sequencer.py @@ -4,7 +4,7 @@ from pubsub import pub from fixate.core.common import TestList, TestClass from fixate.core.exceptions import SequenceAbort, CheckFail -from fixate.core.ui import user_retry_abort_fail +from fixate._ui import _user_retry_abort_fail from fixate.core.checks import CheckResult STATUS_STATES = ["Idle", "Running", "Paused", "Finished", "Restart", "Aborted"] @@ -377,7 +377,7 @@ def retry_prompt(self): if self.non_interactive: return False - status, resp = user_retry_abort_fail(msg="") + resp = _user_retry_abort_fail(msg="") if resp == "ABORT": raise SequenceAbort("Sequence Aborted By User") else: diff --git a/src/fixate/ui_cmdline/cmd_line.py b/src/fixate/ui_cmdline/cmd_line.py index dabf0384..af0b580f 100644 --- a/src/fixate/ui_cmdline/cmd_line.py +++ b/src/fixate/ui_cmdline/cmd_line.py @@ -95,6 +95,8 @@ def register_cmd_line(): pub.subscribe(_print_test_skip, "Test_Skip") pub.subscribe(_print_test_retry, "Test_Retry") pub.subscribe(_user_action, "UI_action") + pub.subscribe(_user_image_or_gif, "UI_image", caller="UI_image") + pub.subscribe(_user_image_or_gif, "UI_gif", caller="UI_gif") key_hook.install() return @@ -105,14 +107,19 @@ def unregister_cmd_line(): return -def reformat_text(text_str, first_line_fill="", subsequent_line_fill=""): +def _reformat_text(text_str, first_line_fill="", subsequent_line_fill=""): lines = [] + _wrapper_initial_indent = wrapper.initial_indent + _wrapper_subsequent_indent = wrapper.subsequent_indent wrapper.initial_indent = first_line_fill wrapper.subsequent_indent = subsequent_line_fill for ind, line in enumerate(text_str.splitlines()): if ind != 0: wrapper.initial_indent = subsequent_line_fill lines.append(wrapper.fill(line)) + # reset the indents, calls to this method should not affect the global state + wrapper.initial_indent = _wrapper_initial_indent + wrapper.subsequent_indent = _wrapper_subsequent_indent return "\n".join(lines) @@ -133,7 +140,7 @@ def _user_action(msg, callback_obj): None """ print("\a") - print(reformat_text(msg)) + print(_reformat_text(msg)) print('Press escape or "f" to fail') cancel_queue = Queue() callback_obj.set_user_cancel_queue(cancel_queue) @@ -142,98 +149,28 @@ def _user_action(msg, callback_obj): key_hook.start_monitor(cancel_queue, {b"\x1b": False, b"f": False}) -def _user_ok(msg, q): - """ - This can be replaced anywhere in the project that needs to implement the user driver - The result needs to be put in the queue with the first part of the tuple as 'Exception' or 'Result' and the second - part is the exception object or response object - :param msg: - Message for the user to understand what to do - :param q: - The result queue of type queue.Queue - :return: - """ - msg = reformat_text(msg + "\n\nPress Enter to continue...") +def _user_ok(msg): + msg = _reformat_text(msg + "\n\nPress Enter to continue...") print("\a") input(msg) - q.put("Result", None) -def _user_choices(msg, q, choices, target, attempts=5): - """ - This can be replaced anywhere in the project that needs to implement the user driver - Temporarily a simple input function. - The result needs to be put in the queue with the first part of the tuple as 'Exception' or 'Result' and the second - part is the exception object or response object - This needs to be compatible with forced exit. Look to user action for how it handles a forced exit - :param msg: - Message for the user to understand what to input - :param q: - The result queue of type queue.Queue - :param target: - Optional - Validation function to check if the user response is valid - :param attempts: - :param args: - :param kwargs: - :return: - """ - choicesstr = "\n" + ", ".join(choices[:-1]) + " or " + choices[-1] + " " - for _ in range(attempts): - # This will change based on the interface - print("\a") - ret_val = input(reformat_text(msg + choicesstr)) - ret_val = target(ret_val, choices) - if ret_val: - q.put(("Result", ret_val)) - return - q.put( - "Exception", - UserInputError("Maximum number of attempts {} reached".format(attempts)), - ) +def _user_choices(msg, q, choices): + choicesstr = "\n" + ", ".join(choices[:-1]) + " or " + choices[-1] + print("\a") + ret_val = input(_reformat_text(msg + choicesstr) + " ") + q.put(ret_val) -def _user_input(msg, q, target=None, attempts=5, kwargs=None): +def _user_input(msg, q): """ - This can be replaced anywhere in the project that needs to implement the user driver - Temporarily a simple input function. - The result needs to be put in the queue with the first part of the tuple as 'Exception' or 'Result' and the second - part is the exception object or response object - This needs to be compatible with forced exit. Look to user action for how it handles a forced exit - :param msg: - Message for the user to understand what to input - :param q: - The result queue of type queue.Queue - :param target: - Optional - Validation function to check if the user response is valid - :param attempts: - :param args: - :param kwargs: - :return: + Get raw user input and put in on the queue. """ initial_indent = ">>> " - subsequent_indent = " " - # additional space added due to wrapper.drop_white_space being True, need to - # drop white spaces, but keep the white space to separate the cursor from input message - msg = reformat_text(msg, initial_indent, subsequent_indent) + "\n>>> " - wrapper.initial_indent = "" - wrapper.subsequent_indent = "" - for _ in range(attempts): - # This will change based on the interface - print("\a") - ret_val = input(msg) - if target is None: - q.put(ret_val) - return - ret_val = target(ret_val, **kwargs) - if ret_val: - q.put(("Result", ret_val)) - return - # Display failure of target and send exception - error_str = f"Maximum number of attempts {attempts} reached. {target.__doc__}" - _user_display(error_str) - q.put(("Exception", UserInputError(error_str))) + subsequent_indent = " " # TODO - determine is this is needed + print("\a") + resp = input(_reformat_text(msg, initial_indent, subsequent_indent) + "\n>>> ") + q.put(resp) def _user_display(msg): @@ -242,26 +179,39 @@ def _user_display(msg): :param important: creates a line of "!" either side of the message :return: """ - print(reformat_text(msg)) + print(_reformat_text(msg)) -def _user_display_important(msg): +def _user_display_important(msg, colour=None, bg_colour=None): """ :param msg: :param important: creates a line of "!" either side of the message :return: """ + # ignore the colours that are used in the GUI print("") print("!" * wrapper.width) print("") - print(reformat_text(msg)) + print(_reformat_text(msg)) print("") print("!" * wrapper.width) +def _user_image_or_gif(path, caller): + if caller == "UI_image": + disp_str = "image" + else: + disp_str = "GIF" + print("\a") + _user_display_important(f"Oh oh, {disp_str} display not supported in command line") + print( + _reformat_text(f"This {disp_str} would have been displayed in the GUI: {path}") + ) + + def _print_sequence_end(status, passed, failed, error, skipped, sequence_status): print("#" * wrapper.width) - print(reformat_text("Sequence {}".format(sequence_status))) + print(_reformat_text("Sequence {}".format(sequence_status))) # print("Sequence {}".format(sequence_status)) post_sequence_info = RESOURCES["SEQUENCER"].context_data.get( "_post_sequence_info", {} @@ -272,13 +222,13 @@ def _print_sequence_end(status, passed, failed, error, skipped, sequence_status) for msg, state in post_sequence_info.items(): if status == "PASSED": if state == "PASSED" or state == "ALL": - print(reformat_text(msg)) + print(_reformat_text(msg)) elif state != "PASSED": - print(reformat_text(msg)) + print(_reformat_text(msg)) print("-" * wrapper.width) # reformat_text - print(reformat_text("Status: {}".format(status))) + print(_reformat_text("Status: {}".format(status))) # print("Status: {}".format(status)) print("#" * wrapper.width) print("\a") @@ -286,7 +236,7 @@ def _print_sequence_end(status, passed, failed, error, skipped, sequence_status) def _print_test_start(data, test_index): print("*" * wrapper.width) - print(reformat_text("Test {}: {}".format(test_index, data.test_desc))) + print(_reformat_text("Test {}: {}".format(test_index, data.test_desc))) # print("Test {}: {}".format(test_index, data.test_desc)) print("-" * wrapper.width) @@ -295,14 +245,14 @@ def _print_test_complete(data, test_index, status): sequencer = RESOURCES["SEQUENCER"] print("-" * wrapper.width) print( - reformat_text( + _reformat_text( "Checks passed: {}, Checks failed: {}".format( sequencer.chk_pass, sequencer.chk_fail ) ) ) # print("Checks passed: {}, Checks failed: {}".format(sequencer.chk_pass, sequencer.chk_fail)) - print(reformat_text("Test {}: {}".format(test_index, status.upper()))) + print(_reformat_text("Test {}: {}".format(test_index, status.upper()))) # print("Test {}: {}".format(test_index, status.upper())) print("-" * wrapper.width) @@ -312,14 +262,14 @@ def _print_test_skip(data, test_index): def _print_test_retry(data, test_index): - print(reformat_text("\nTest {}: Retry".format(test_index))) + print(_reformat_text("\nTest {}: Retry".format(test_index))) def _print_errors(exception, test_index): print("") print("!" * wrapper.width) print( - reformat_text( + _reformat_text( "Test {}: Exception Occurred, {} {}".format( test_index, type(exception), exception ) @@ -334,4 +284,4 @@ def _print_errors(exception, test_index): def _print_comparisons(passes: bool, chk: CheckResult, chk_cnt: int, context: str): msg = f"\nCheck {chk_cnt}: " + chk.check_string - print(reformat_text(msg)) + print(_reformat_text(msg)) diff --git a/src/fixate/ui_gui_qt/ui_gui_qt.py b/src/fixate/ui_gui_qt/ui_gui_qt.py index a5e03564..bba32280 100644 --- a/src/fixate/ui_gui_qt/ui_gui_qt.py +++ b/src/fixate/ui_gui_qt/ui_gui_qt.py @@ -269,7 +269,7 @@ def on_image_update(self, path): except (FileNotFoundError, OSError): # When running direct from the file system, if an image isn't found we # get FileNotFoundError. When running from a zip file, we get OSError - logger.exception("Image path specific in the test script was invalid") + logger.exception("Image path specified in the test script was invalid") # message dialog so the user knows the image didn't load self.file_not_found(path) else: @@ -634,7 +634,7 @@ def on_finish(self): """User IO handlers, emit signals to trigger main thread updates via slots. These are run in the sequencer thread""" - def gui_user_input(self, message, choices=None): + def _gui_user_input(self, message, choices=None): if choices is not None: # Button Prompt self.sig_choices_input.emit(message, choices) else: # Text Prompt @@ -738,96 +738,32 @@ def _topic_UI_action(self, msg, callback_obj): callback_obj.set_target_finished_callback(self.sig_button_reset.emit) self.sig_choices_input.emit(msg, ("Fail",)) - def _topic_UI_req(self, msg, q): - """ - This can be replaced anywhere in the project that needs to implement the user driver - The result needs to be put in the queue with the first part of the tuple as 'Exception' or 'Result' and the - second part is the exception object or response object - :param msg: - Message for the user to understand what to do - :param q: - The result queue of type queue.Queue - :return: - """ + def _topic_UI_req(self, msg): if self.closing: - q.put("Result", None) return - self.gui_user_input(msg, ("Continue",)) - q.put("Result", None) - def _topic_UI_req_choices(self, msg, q, choices, target, attempts=5): - """ - This can be replaced anywhere in the project that needs to implement the user driver - Temporarily a simple input function. - The result needs to be put in the queue with the first part of the tuple as 'Exception' or 'Result' and the - second part is the exception object or response object - This needs to be compatible with forced exit. Look to user action for how it handles a forced exit - :param msg: - Message for the user to understand what to input - :param q: - The result queue of type queue.Queue - :param target: - Optional - Validation function to check if the user response is valid - :param attempts: - :return: - """ + self._gui_user_input(msg, ("Continue",)) + + def _topic_UI_req_choices(self, msg, q, choices): if self.closing: + # I don't think the result of this code ever gets used, nothing looking for "ABORT_FORCE" + # unpacks from a tuple. This can probably be deleted. q.put(("Result", "ABORT_FORCE")) return - for _ in range(attempts): - # This will change based on the interface - ret_val = self.gui_user_input(self.reformat_text(msg), choices) - ret_val = target(ret_val, choices) - if ret_val: - q.put(("Result", ret_val)) - return - q.put( - "Exception", - UserInputError("Maximum number of attempts {} reached".format(attempts)), - ) + ret_val = self._gui_user_input(msg, choices) + q.put(ret_val) - def _topic_UI_req_input(self, msg, q, target=None, attempts=5, kwargs=None): - """ - This can be replaced anywhere in the project that needs to implement the user driver - Temporarily a simple input function. - The result needs to be put in the queue with the first part of the tuple as 'Exception' or 'Result' and the - second part is the exception object or response object - This needs to be compatible with forced exit. Look to user action for how it handles a forced exit - :param msg: - Message for the user to understand what to input - :param q: - The result queue of type queue.Queue - :param target: - Optional - Validation function to check if the user response is valid - :param attempts: - - :param kwargs: - :return: - """ + def _topic_UI_req_input(self, msg, q): if self.closing: + # I don't think the result of this code ever gets used, nothing looking for "ABORT_FORCE" + # unpacks from a tuple. This can probably be deleted. q.put(("Result", "ABORT_FORCE")) return msg = self.reformat_text(msg) - wrapper.initial_indent = "" - wrapper.subsequent_indent = "" - for _ in range(attempts): - # This will change based on the interface - ret_val = self.gui_user_input(msg, None) - if target is None or ret_val == "ABORT_FORCE": - q.put(ret_val) - return - ret_val = target(ret_val, **kwargs) - if ret_val: - q.put(("Result", ret_val)) - return - # Display failure of target and send exception - error_str = f"Maximum number of attempts {attempts} reached. {target.__doc__}" - self._topic_UI_display(error_str) - q.put(("Exception", UserInputError(error_str))) + ret_val = self._gui_user_input(msg, None) + q.put(ret_val) def _topic_UI_display(self, msg): """ @@ -839,7 +775,7 @@ def _topic_UI_display(self, msg): self.sig_active_update.emit(self.reformat_text(msg)) self.sig_history_update.emit(self.reformat_text(msg)) - def _topic_UI_display_important(self, msg): + def _topic_UI_display_important(self, msg, colour="red", bg_colour="white"): """ :param msg: :return: @@ -847,15 +783,24 @@ def _topic_UI_display_important(self, msg): if self.closing: return + txt_fill = "!" * wrapper.width + + # Hisotry window self.sig_history_update.emit("") - self.sig_history_update.emit("!" * wrapper.width) - self.sig_active_update.emit("!" * wrapper.width) + self.sig_history_update.emit(txt_fill) self.sig_history_update.emit("") self.sig_history_update.emit(self.reformat_text(msg)) - self.sig_active_update.emit(self.reformat_text(msg)) self.sig_history_update.emit("") - self.sig_history_update.emit("!" * wrapper.width) - self.sig_active_update.emit("!" * wrapper.width) + self.sig_history_update.emit(txt_fill) + + # Active window + self.sig_active_update.emit( + f"{txt_fill}" + ) + self.sig_active_update.emit(self.reformat_text(msg)) + self.sig_active_update.emit( + f"{txt_fill}" + ) def _topic_Sequence_Complete( self, status, passed, failed, error, skipped, sequence_status diff --git a/test/core/test_ui_requests.py b/test/core/test_ui_requests.py index 25a3cd2e..b5f0f756 100644 --- a/test/core/test_ui_requests.py +++ b/test/core/test_ui_requests.py @@ -1,22 +1,19 @@ import unittest from unittest.mock import MagicMock from pubsub import pub -from fixate.core.ui import _user_req_input, _float_validate, user_serial +from fixate.core.ui import ( + _user_req_input, + _float_validate, + user_serial, + user_input_float, + _ten_digit_serial, +) +from fixate.core.exceptions import UserInputError class MockUserDriver(MagicMock): - def execute_target(self, msg, q, target=None, attempts=5, kwargs=None): - if target: - try: - if self.test_value is None: - ret_val = target(**kwargs) - else: - ret_val = target(self.test_value, **kwargs) - q.put(("Result", ret_val)) - except Exception as e: - q.put(("Exception", e)) - else: - q.put(("Result", self.test_value)) + def execute_target(self, msg, q): + q.put(self.test_value) class TestUserRequest(unittest.TestCase): @@ -30,34 +27,40 @@ def tearDown(self): def test_read_from_queue(self): self.mock.test_value = "World" - self.assertEqual(self.test_method("message"), ("Result", "World")) + self.assertEqual(self.test_method("message"), "World") def test_target_check(self): self.mock.return_value = "World" self.assertEqual(self.test_method("HI", target=self.mock), ("Result", "World")) + def test_float_validate_fails(self): + # _float_validate is tested implicitly by user_input_float, but because of how the + # failures are done at that level, we should check the failure case here + self.assertFalse(_float_validate("abc")) + def test_target_float(self): self.mock.test_value = "1.23" - resp = self.test_method("message", target=_float_validate) + self.test_method = user_input_float + resp = self.test_method("message") self.assertAlmostEqual(resp[1], float(self.mock.test_value)) def test_target_float_fails(self): self.mock.test_value = "abc" - resp = self.test_method("message", target=_float_validate) - self.assertFalse(resp[1]) + self.test_method = user_input_float + resp = self.test_method("message") + self.assertTrue(isinstance(resp[1], UserInputError)) def test_user_serial(self): self.mock.test_value = "1234567890" resp = user_serial("message") self.assertEqual(resp[1], int(self.mock.test_value)) + def test_ten_digit_serial_fails(self): + # _ten_digit_serial is tested implicitly by user_serial since it's the default, but because of how the + # failures are done at that level, we should check the failure case here + self.assertEqual(_ten_digit_serial("123456789"), False) + def test_user_serial_fail(self): self.mock.test_value = "123456789" # < 10 digits resp = user_serial("message") - self.assertFalse(resp[1]) - - def test_user_serial_no_target(self): - # Not really meaningful test? - self.mock.test_value = 123 - resp = user_serial("message", None) - self.assertEqual(resp[1], self.mock.test_value) + self.assertTrue(isinstance(resp[1], UserInputError)) diff --git a/test/test_ui.py b/test/test_ui.py new file mode 100644 index 00000000..9ba83a85 --- /dev/null +++ b/test/test_ui.py @@ -0,0 +1,125 @@ +import pytest +import pubsub + +from fixate.core.exceptions import UserInputError +from fixate import ( + user_input, + user_input_float, + user_serial, + Validator, + user_yes_no, +) + +# Mock the UI interface +class MockUserInterface: + def execute_target(self, msg, q): + q.put(self.test_value) + + +@pytest.fixture +def mock_user_interface(): + mock = MockUserInterface() + pubsub.pub.subscribe(mock.execute_target, "UI_req_input") + return mock + + +class MockUserInterfaceChoices: + def execute_target(self, msg, q, choices): + q.put(self.test_value) + + +@pytest.fixture +def mock_user_interface_choices(): + mock = MockUserInterfaceChoices() + pubsub.pub.subscribe(mock.execute_target, "UI_req_choices") + return mock + + +def test_user_input(mock_user_interface): + mock_user_interface.test_value = "Hello" + resp = user_input("message") + assert resp == "Hello" + + +def test_user_input_float(mock_user_interface): + mock_user_interface.test_value = "1.23" + resp = user_input_float("message") + assert resp == 1.23 + + +def test_user_input_float_0(mock_user_interface): + # test that 0 is a valid float, prevent issue #213 from recurring + mock_user_interface.test_value = "0" + resp = user_input_float("message") + assert resp == 0.0 + + +def test_user_input_float_fails(mock_user_interface): + mock_user_interface.test_value = "abc" + with pytest.raises(UserInputError): + user_input_float("message") + + +def test_user_serial(mock_user_interface): + mock_user_interface.test_value = "1234567890" + resp = user_serial("message") + assert resp == 1234567890 + + +def test_user_serial_fails(mock_user_interface): + mock_user_interface.test_value = "abc" + with pytest.raises(UserInputError): + user_serial("message") + + +def test_user_serial_custom_validator(mock_user_interface): + mock_user_interface.test_value = "240712345" + serial_validator = Validator( + lambda x: x.startswith("2407"), "Serial must be from July 2024 - 2407" + ) + resp = user_serial("message", validator=serial_validator) + assert resp == 240712345 + + +def test_user_serial_custom_validator_fails(mock_user_interface): + mock_user_interface.test_value = "240612345" + serial_validator = Validator( + lambda x: x.startswith("2407"), "Serial must be from July 2024 - 2407" + ) + with pytest.raises(UserInputError): + user_serial("message", validator=serial_validator) + + +def test_user_serial_str(mock_user_interface): + mock_user_interface.test_value = "abcdefgh" + serial_validator = Validator( + lambda x: x.startswith("abc"), "Serial must start with 'abc'" + ) + resp = user_serial("message", validator=serial_validator, return_type=str) + assert resp == "abcdefgh" + + +# the user_yes_no tests implicitly test the _user_choices function, so no need +# to test the _user_retry_abort_fail function +def test_user_yes_no_yes(mock_user_interface_choices): + mock_user_interface_choices.test_value = "yes" + resp = user_yes_no("message") + assert resp == "YES" + + +def test_user_yes_no_y(mock_user_interface_choices): + mock_user_interface_choices.test_value = "y" + resp = user_yes_no("message") + assert resp == "YES" + + +def test_user_yes_no_no(mock_user_interface_choices): + mock_user_interface_choices.test_value = "no" + resp = user_yes_no("message") + assert resp == "NO" + + +def test_user_yes_no_fails(mock_user_interface_choices): + mock_user_interface_choices.test_value = "maybe" + with pytest.raises(UserInputError): + user_yes_no("message")