diff --git a/openc3/lib/openc3/script/api_shared.rb b/openc3/lib/openc3/script/api_shared.rb index 3e3a0d7064..568e55f169 100644 --- a/openc3/lib/openc3/script/api_shared.rb +++ b/openc3/lib/openc3/script/api_shared.rb @@ -288,6 +288,7 @@ def wait_check(*args, type: :CONVERTED, scope: $openc3_scope, token: $openc3_tok start_time = Time.now.sys success, value = _openc3_script_wait_implementation_comparison(target_name, packet_name, item_name, type, comparison_to_eval, timeout, polling_rate, scope: scope, token: token, &block) value = "'#{value}'" if value.is_a? String # Show user the check against a quoted string + value = 'nil' if value.nil? # Show user nil value as 'nil' time_diff = Time.now.sys - start_time check_str = "CHECK: #{_upcase(target_name, packet_name, item_name)}" if comparison_to_eval @@ -531,7 +532,7 @@ def _check(*args, scope: $openc3_scope, token: $openc3_token) if comparison_to_eval _check_eval(target_name, packet_name, item_name, comparison_to_eval, value) else - puts "CHECK: #{_upcase(target_name, packet_name, item_name)} == #{value}" + puts "CHECK: #{_upcase(target_name, packet_name, item_name)} == #{value.nil? ? 'nil' : value.inspect}" end end @@ -632,6 +633,7 @@ def _execute_wait(target_name, packet_name, item_name, value_type, comparison_to start_time = Time.now.sys success, value = _openc3_script_wait_implementation_comparison(target_name, packet_name, item_name, value_type, comparison_to_eval, timeout, polling_rate, scope: scope, token: token) value = "'#{value}'" if value.is_a? String # Show user the check against a quoted string + value = 'nil' if value.nil? # Show user nil value as 'nil' time_diff = Time.now.sys - start_time wait_str = "WAIT: #{_upcase(target_name, packet_name, item_name)} #{comparison_to_eval}" value_str = "with value == #{value} after waiting #{time_diff} seconds" @@ -863,8 +865,20 @@ def _check_eval(target_name, packet_name, item_name, comparison_to_eval, value) # Show user the check against a quoted string # Note: We have to preserve the original 'value' variable because we're going to eval against it value_str = value.is_a?(String) ? "'#{value}'" : value + value_str = 'nil' if value.nil? # Show user nil value as 'nil' with_value = "with value == #{value_str}" - if eval(string) + + eval_is_valid = _check_eval_validity(value, comparison_to_eval) + unless eval_is_valid + message = "Invalid comparison for types" + if $disconnect + puts "ERROR: #{message}" + else + raise CheckError, message + end + end + + if eval_is_valid && eval(string) puts "#{check_str} success #{with_value}" else message = "#{check_str} failed #{with_value}" @@ -883,5 +897,28 @@ def _check_eval(target_name, packet_name, item_name, comparison_to_eval, value) raise e end end + + def _check_eval_validity(value, comparison) + return true if comparison.nil? || comparison.empty? + + begin + operator, operand = extract_operator_and_operand_from_comparison(comparison) + rescue RuntimeError => e + if e.message.include?("Unable to parse operand") + # If we can't parse the operand, let the eval happen anyway + # It will raise an appropriate error (like NameError for undefined constants) + return true + end + raise # Re-raise invalid operator errors + rescue JSON::ParserError + return true + end + + if [">=", "<=", ">", "<"].include?(operator) + return false if value.nil? || operand.nil? || value.is_a?(Array) || operand.is_a?(Array) + end + + return true + end end end diff --git a/openc3/lib/openc3/script/extract.rb b/openc3/lib/openc3/script/extract.rb index 4c2a65b4cd..3f1679d34b 100644 --- a/openc3/lib/openc3/script/extract.rb +++ b/openc3/lib/openc3/script/extract.rb @@ -15,6 +15,7 @@ # This file may also be used under the terms of a commercial license # if purchased from OpenC3, Inc. +require 'json' require 'openc3/utilities/store' module OpenC3 @@ -154,22 +155,54 @@ def extract_fields_from_set_tlm_text(text) end def extract_fields_from_check_text(text) - split_string = text.split - raise "ERROR: Check improperly specified: #{text}" if split_string.length < 3 + target_name, packet_name, item_name, comparison = text.split(nil, 4) # Ruby: second split arg is max number of resultant elements + raise "ERROR: Check improperly specified: #{text}" if item_name.nil? - target_name = split_string[0] - packet_name = split_string[1] - item_name = split_string[2] - comparison_to_eval = nil - return [target_name, packet_name, item_name, comparison_to_eval] if split_string.length == 3 - raise "ERROR: Check improperly specified: #{text}" if split_string.length < 4 + # comparison is either nil, the comparison string, or an empty string. + # We need it to not be an empty string. + comparison = nil if comparison&.length == 0 + + operator, _ = comparison&.split(nil, 2) + raise "ERROR: Use '==' instead of '=' in #{text}" if operator == "=" + + return [target_name, packet_name, item_name, comparison] + end + + # Splits `check()` comparison expressions, e.g. "== 'foo bar'" becomes ["==", "foo bar"] + def extract_operator_and_operand_from_comparison(comparison) + valid_operators = ["==", "!=", ">=", "<=", ">", "<", "in"] - split_string = text.split(/ /) # Split on regex spaces to preserve spaces in comparison - index = split_string.rindex(item_name) - comparison_to_eval = split_string[(index + 1)..(split_string.length - 1)].join(" ") - raise "ERROR: Use '==' instead of '=': #{text}" if split_string[3] == '=' + operator, operand = comparison.split(nil, 2) # Ruby: second split arg is max number of resultant elements - return [target_name, packet_name, item_name, comparison_to_eval] + if operand.nil? + # Don't allow operator without operand + raise "ERROR: Invalid comparison, must specify an operand: #{comparison}" if !operator.nil? + return [nil, nil] + end + + raise "ERROR: Invalid operator: '#{operator}'" unless valid_operators.include?(operator) + + # Handle string operand: remove surrounding double/single quotes + if operand.match?(/^(['"])(.*)\1$/m) # Starts with single or double quote, and ends with matching quote + operand = operand[1..-2] + return [operator, operand] + end + + # Handle other operand types + if operand == "nil" + operand = nil + elsif operand == "false" + operand = false + elsif operand == "true" + operand = true + else + begin + operand = JSON.parse(operand) + rescue JSON::ParserError + raise "ERROR: Unable to parse operand: #{operand}" + end + end + return [operator, operand] end end end diff --git a/openc3/python/openc3/api/api_shared.py b/openc3/python/openc3/api/api_shared.py index 0f9b1aa078..097ed412e5 100644 --- a/openc3/python/openc3/api/api_shared.py +++ b/openc3/python/openc3/api/api_shared.py @@ -9,6 +9,7 @@ # This file may also be used under the terms of a commercial license # if purchased from OpenC3, Inc. +import json import sys import time import traceback @@ -19,6 +20,7 @@ from openc3.utilities.extract import ( extract_fields_from_check_text, extract_fields_from_tlm_text, + extract_operator_and_operand_from_comparison, ) @@ -965,16 +967,21 @@ def _check_eval(target_name, packet_name, item_name, comparison_to_eval, value): else: value_str = value with_value = f"with value == {value_str}" + try: - if eval(string): + eval_is_valid = _check_eval_validity(value, comparison_to_eval) + if eval_is_valid and eval(string): print(f"{check_str} success {with_value}") else: message = f"{check_str} failed {with_value}" raise CheckError(message) - except NameError as error: - parts = error.args[0].split("'") - new_error = NameError(f"Uninitialized constant {parts[1]}. Did you mean '{parts[1]}' as a string?") - raise new_error from error + except (NameError, json.JSONDecodeError) as error: + if isinstance(error, NameError): + parts = error.args[0].split("'") + new_error = NameError(f"Uninitialized constant {parts[1]}. Did you mean '{parts[1]}' as a string?") + raise new_error from error + else: + raise def _frange(value): @@ -986,6 +993,32 @@ def _frange(value): return value +def _check_eval_validity(value, comparison): + if not comparison: + return True + + try: + operator, operand = extract_operator_and_operand_from_comparison(comparison) + except RuntimeError as e: + if "Unable to parse operand" in str(e): + # If we can't parse the operand, let the eval happen anyway + # It will raise an appropriate error (like NameError for undefined constants) + return True + raise # Re-raise invalid operator errors + except json.JSONDecodeError: + return True + + if operator in [">=", "<=", ">", "<"] and ( + value is None or operand is None or isinstance(value, list) or isinstance(operand, list) + ): + return False + + # Ruby doesn't have the "in" operator + return not ( + operator == "in" and (isinstance(operand, str) and not isinstance(value, str) or not isinstance(operand, list)) + ) + + # Interesting formatter to a specific number of significant digits: # https://stackoverflow.com/questions/3410976/how-to-round-a-number-to-significant-figures-in-python?rq=3 # def format(value, sigfigs=9): diff --git a/openc3/python/openc3/script/api_shared.py b/openc3/python/openc3/script/api_shared.py index 06d6c3d480..38fbc44695 100644 --- a/openc3/python/openc3/script/api_shared.py +++ b/openc3/python/openc3/script/api_shared.py @@ -9,6 +9,7 @@ # This file may also be used under the terms of a commercial license # if purchased from OpenC3, Inc. +import json import sys import time import traceback @@ -19,6 +20,7 @@ from openc3.utilities.extract import ( extract_fields_from_check_text, extract_fields_from_tlm_text, + extract_operator_and_operand_from_comparison, ) from .exceptions import CheckError @@ -1046,8 +1048,16 @@ def _check_eval(target_name, packet_name, item_name, comparison_to_eval, value): else: value_str = value with_value = f"with value == {value_str}" + + eval_is_valid = _check_eval_validity(value, comparison_to_eval) + if not eval_is_valid: + message = "Invalid comparison for types" + if openc3.script.DISCONNECT: + print(f"ERROR: {message}") + else: + raise CheckError(message) try: - if eval(string): + if eval_is_valid and eval(string): print(f"{check_str} success {with_value}") else: message = f"{check_str} failed {with_value}" @@ -1070,6 +1080,32 @@ def _frange(value): return value +def _check_eval_validity(value, comparison): + if not comparison: + return True + + try: + operator, operand = extract_operator_and_operand_from_comparison(comparison) + except RuntimeError as e: + if "Unable to parse operand" in str(e): + # If we can't parse the operand, let the eval happen anyway + # It will raise an appropriate error (like NameError for undefined constants) + return True + raise # Re-raise invalid operator errors + except json.JSONDecodeError: + return True + + if operator in [">=", "<=", ">", "<"] and ( + value is None or operand is None or isinstance(value, list) or isinstance(operand, list) + ): + return False + + # Ruby doesn't have the "in" operator + return not ( + operator == "in" and (isinstance(operand, str) and not isinstance(value, str) or not isinstance(operand, list)) + ) + + # Interesting formatter to a specific number of significant digits: # https://stackoverflow.com/questions/3410976/how-to-round-a-number-to-significant-figures-in-python?rq=3 # def format(value, sigfigs=9): diff --git a/openc3/python/openc3/utilities/extract.py b/openc3/python/openc3/utilities/extract.py index 6fec772502..1a08a71067 100644 --- a/openc3/python/openc3/utilities/extract.py +++ b/openc3/python/openc3/utilities/extract.py @@ -7,21 +7,23 @@ # See LICENSE.md for more details. # # Modified by OpenC3, Inc. -# All changes Copyright 2025, OpenC3, Inc. +# All changes Copyright 2026, OpenC3, Inc. # All Rights Reserved # # This file may also be used under the terms of a commercial license # if purchased from OpenC3, Inc. import ast +import json import re SCANNING_REGULAR_EXPRESSION = re.compile( - r"(?:\"(?:[^\\\"]|\\.)*\") | (?:'(?:[^\\']|\\.)*') | (?:\[.*\]) | \S+", re.VERBOSE + r"(?:\"(?:[^\\\"]|\\.)*\") | (?:'(?:[^\\']|\\.)*') | (?:\[(?:[^\\\[\]]|\\.)*\]) | \S+", re.VERBOSE ) SPLIT_WITH_REGEX = re.compile(r"\s+with\s+", re.IGNORECASE) +SPLIT_WITH_OPTIONAL_WHITESPACE_REGEX = re.compile(r"\s*with\s*", re.IGNORECASE) # Regular expression to identify a String as a floating point number FLOAT_CHECK_REGEX = re.compile(r"\A\s*[-+]?\d*\.\d+\s*\Z") @@ -135,8 +137,12 @@ def add_cmd_parameter(keyword, value, cmd_params): def extract_fields_from_cmd_text(text): - split_string = re.split(SPLIT_WITH_REGEX, text, maxsplit=2) - if len(split_string) == 1 and SPLIT_WITH_REGEX.match(text): + split_string = re.split(SPLIT_WITH_REGEX, text, maxsplit=1) # 1 split, therefore 2 elements + if len(split_string) == 0 or split_string[0] == "": + raise RuntimeError("ERROR: text must not be empty") + if (len(split_string) == 1 and re.search(SPLIT_WITH_OPTIONAL_WHITESPACE_REGEX, text)) or ( + len(split_string) == 2 and split_string[1] == "" + ): raise RuntimeError(f"ERROR: 'with' must be followed by parameters : {text:s}") # Extract target_name and cmd_name @@ -182,7 +188,7 @@ def extract_fields_from_cmd_text(text): def extract_fields_from_tlm_text(text): - split_string = text.split(" ") + split_string = text.split() if len(split_string) != 3: raise RuntimeError(f"ERROR: Telemetry Item must be specified as 'TargetName PacketName ItemName' : {text}") target_name = split_string[0] @@ -215,21 +221,60 @@ def extract_fields_from_set_tlm_text(text): def extract_fields_from_check_text(text): - split_string = text.split(" ") - if len(split_string) < 3: - raise RuntimeError(f"ERROR: Check improperly specified: {text}") - target_name = split_string[0] - packet_name = split_string[1] - item_name = split_string[2] - comparison_to_eval = None - if len(split_string) == 3: - return [target_name, packet_name, item_name, comparison_to_eval] - if len(split_string) < 4: + fields_split = text.split(None, 3) # Python: second split arg is max number of splits + if len(fields_split) < 3: raise RuntimeError(f"ERROR: Check improperly specified: {text}") + target_name, packet_name, item_name, *comparison = fields_split - # TODO: Ruby version has additional code to split on regex spaces - comparison_to_eval = " ".join(split_string[3:]) - if split_string[3] == "=": - raise RuntimeError(f"ERROR: Use '==' instead of '=': {text}") - - return target_name, packet_name, item_name, comparison_to_eval + # comparison is a list, guaranteed to be of length 0 or 1 because of the split 3 with the splat operator above. + # We need it to be either None or the comparison string. + if len(comparison): + comparison = comparison[0] + else: + comparison = None + + if comparison and len(comparison): + operator, *_ = comparison.split(None, 1) + if operator == "=": + raise RuntimeError(f"ERROR: Use '==' instead of '=' in {text}") + + return target_name, packet_name, item_name, comparison + + +# Splits `check()` comparison expressions, e.g. "== 'foo bar'" becomes ["==", "foo bar"] +def extract_operator_and_operand_from_comparison(comparison): + valid_operators = ["==", "!=", ">=", "<=", ">", "<", "in"] + + parts = comparison.split(None, 1) # Python: second split arg is max number of splits + operator = parts[0] if len(parts) >= 1 else None + operand = parts[1] if len(parts) >= 2 else None + + if operand is None: + if operator is not None: + raise RuntimeError(f"ERROR: Invalid comparison, must specify an operand: {comparison}") + return [None, None] + + if operator not in valid_operators: + raise RuntimeError(f"ERROR: Invalid operator: '{operator}'") + + # Handle string operand: remove surrounding double/single quotes + quote_match = re.match( + r"^(['\"])(.*)\1$", operand, re.DOTALL + ) # Starts with single or double quote, and ends with matching quote + if quote_match: + operand = quote_match.group(2) + return operator, operand + + # Handle other operand types + if operand == "None": + operand = None + elif operand == "False": + operand = False + elif operand == "True": + operand = True + else: + try: + operand = json.loads(operand) + except json.JSONDecodeError as err: + raise RuntimeError(f"ERROR: Unable to parse operand: {operand}") from err + return operator, operand diff --git a/openc3/python/openc3/utilities/running_script.py b/openc3/python/openc3/utilities/running_script.py index b819736767..7033481909 100644 --- a/openc3/python/openc3/utilities/running_script.py +++ b/openc3/python/openc3/utilities/running_script.py @@ -1155,11 +1155,9 @@ def handle_exception(self, exc_type, exc_value, exc_traceback, fatal, filename=N elif exc_type == CheckError: Logger.error(str(exc_value)) else: - formatted_lines = traceback.format_exception(exc_type, exc_value, exc_traceback) - # Print the last 4 lines to show the exception, the ^^^^ line, - # the line itself, and the filename / line number - Logger.error("".join(formatted_lines[-4:])) + Logger.error(f"{exc_type.__name__} : {exc_value}") if os.environ.get("OPENC3_FULL_BACKTRACE"): + formatted_lines = traceback.format_exception(exc_type, exc_value, exc_traceback) Logger.error("\n".join(formatted_lines)) self.handle_output_io(filename, line_number) diff --git a/openc3/python/test/script/test_api_shared.py b/openc3/python/test/script/test_api_shared.py index d18713fdf8..aa37cf18d0 100644 --- a/openc3/python/test/script/test_api_shared.py +++ b/openc3/python/test/script/test_api_shared.py @@ -179,6 +179,150 @@ def test_check_warns_when_checking_a_state_against_a_constant(self): ): check("INST HEALTH_STATUS CCSDSSHF == FALSE") + def test_check_raises_with_an_invalid_operator(self): + with self.assertRaisesRegex(RuntimeError, r"ERROR: Invalid operator: '==='"): + check("INST HEALTH_STATUS TEMP1 === 1") + with self.assertRaisesRegex(RuntimeError, r"ERROR: Invalid operator: 'not'"): + check("INST HEALTH_STATUS TEMP1 not 1") + with self.assertRaisesRegex(RuntimeError, r"ERROR: Invalid operator: 'and'"): + check("INST HEALTH_STATUS TEMP1 and 1") + + def test_check_raises_with_an_operator_but_no_operand(self): + with self.assertRaisesRegex(RuntimeError, r"ERROR: Invalid comparison, must specify an operand: >"): + check("INST HEALTH_STATUS TEMP1 >") + with self.assertRaisesRegex(RuntimeError, r"ERROR: Invalid comparison, must specify an operand: =="): + check("INST HEALTH_STATUS TEMP1 ==") + + def test_check_raises_with_invalid_comparison_for_types(self): + with self.assertRaisesRegex(CheckError, r"Invalid comparison for types"): + check("INST HEALTH_STATUS TEMP1 > None") + with self.assertRaisesRegex(CheckError, r"Invalid comparison for types"): + check("INST HEALTH_STATUS TEMP1 < None") + with self.assertRaisesRegex(CheckError, r"Invalid comparison for types"): + check("INST HEALTH_STATUS TEMP1 >= None") + with self.assertRaisesRegex(CheckError, r"Invalid comparison for types"): + check("INST HEALTH_STATUS TEMP1 <= None") + global count + count = False + with self.assertRaisesRegex(CheckError, r"Invalid comparison for types"): + check("INST HEALTH_STATUS RECEIVED_COUNT > 1") + with self.assertRaisesRegex(CheckError, r"Invalid comparison for types"): + check("INST HEALTH_STATUS RECEIVED_COUNT < 1") + count = True + with self.assertRaisesRegex(CheckError, r"Invalid comparison for types"): + check("INST HEALTH_STATUS ARY > 1") + with self.assertRaisesRegex(CheckError, r"Invalid comparison for types"): + check("INST HEALTH_STATUS ARY < 1") + + def test_check_logs_invalid_comparison_for_types_when_disconnected(self): + openc3.script.DISCONNECT = True + for stdout in capture_io(): + check("INST HEALTH_STATUS TEMP1 > None") + self.assertIn("ERROR: Invalid comparison for types", stdout.getvalue()) + openc3.script.DISCONNECT = False + + def test_check_nil_value_with_equality_operators(self): + global count + count = False + for stdout in capture_io(): + check("INST HEALTH_STATUS RECEIVED_COUNT == None") + self.assertIn( + "CHECK: INST HEALTH_STATUS RECEIVED_COUNT == None success with value == None", + stdout.getvalue(), + ) + with self.assertRaisesRegex( + CheckError, + r"CHECK: INST HEALTH_STATUS RECEIVED_COUNT != None failed with value == None", + ): + check("INST HEALTH_STATUS RECEIVED_COUNT != None") + count = True + + def test_check_prints_none_for_value_with_no_comparison(self): + global count + count = False + for stdout in capture_io(): + check("INST HEALTH_STATUS RECEIVED_COUNT") + self.assertIn("CHECK: INST HEALTH_STATUS RECEIVED_COUNT == None", stdout.getvalue()) + count = True + + def test_wait_tolerance_accepts_polling_rate_with_string_form(self): + for stdout in capture_io(): + result = wait_tolerance("INST HEALTH_STATUS TEMP2", 10.5, 0.01, 0.01, 0.01) + self.assertTrue(result) + self.assertIn( + "WAIT: INST HEALTH_STATUS TEMP2 was within range 10.49 to 10.51 with value == 10.5", + stdout.getvalue(), + ) + + def test_wait_tolerance_accepts_polling_rate_with_explicit_form(self): + for stdout in capture_io(): + result = wait_tolerance("INST", "HEALTH_STATUS", "TEMP2", 1.55, 0.1, 0.01, 0.01, type="RAW") + self.assertTrue(result) + self.assertIn( + "WAIT: INST HEALTH_STATUS TEMP2 was within range 1.45 to 1.65 with value == 1.5", + stdout.getvalue(), + ) + + def test_wait_check_uses_default_polling_rate_with_explicit_form(self): + for stdout in capture_io(): + result = wait_check("INST", "HEALTH_STATUS", "TEMP1", "> 1", 0.01) + self.assertTrue(isinstance(result, float)) + self.assertIn( + "CHECK: INST HEALTH_STATUS TEMP1 > 1 success with value == 10", + stdout.getvalue(), + ) + + def test_wait_returns_true_when_cancelled_and_eval_condition_is_met(self): + global cancel + cancel = True + for stdout in capture_io(): + result = wait("INST HEALTH_STATUS RECEIVED_COUNT >= 2", 0.1, 0.01) + self.assertTrue(result) + self.assertIn("WAIT: INST HEALTH_STATUS RECEIVED_COUNT >= 2 success", stdout.getvalue()) + cancel = False + + def test_wait_returns_false_when_cancelled_and_eval_raises_type_error(self): + global cancel, count + cancel = True + count = False + for stdout in capture_io(): + # RECEIVED_COUNT returns None when count=False, so eval("value >= 0") raises TypeError + result = wait("INST HEALTH_STATUS RECEIVED_COUNT >= 0", 0.1, 0.01) + self.assertFalse(result) + self.assertIn("WARN: WAIT: INST HEALTH_STATUS RECEIVED_COUNT >= 0 failed", stdout.getvalue()) + cancel = False + count = True + + def test_wait_check_succeeds_when_cancelled_and_eval_condition_is_met(self): + global cancel + cancel = True + for stdout in capture_io(): + result = wait_check("INST HEALTH_STATUS RECEIVED_COUNT >= 2", 0.1, 0.01) + self.assertTrue(isinstance(result, float)) + self.assertIn("CHECK: INST HEALTH_STATUS RECEIVED_COUNT >= 2 success", stdout.getvalue()) + cancel = False + + def test_wait_expression_succeeds_when_cancelled_and_expression_becomes_true(self): + global cancel + cancel = True + counter = {"n": 0} + + def bump(): + counter["n"] += 1 + return counter["n"] + + for stdout in capture_io(): + result = wait_expression("bump() >= 2", 0.1, 0.01, {"bump": bump}, {}) + self.assertTrue(result) + self.assertIn("is TRUE", stdout.getvalue()) + cancel = False + + def test_check_eval_validity_returns_true_for_no_comparison(self): + from openc3.script.api_shared import _check_eval_validity + + self.assertTrue(_check_eval_validity(10, None)) + self.assertTrue(_check_eval_validity(10, "")) + def test_checks_against_the_specified_type(self): for stdout in capture_io(): check_raw("INST HEALTH_STATUS TEMP1 == 1") @@ -203,6 +347,13 @@ def test_raises_if_the_exception_is_not_raised(self): ): check_exception("check", "INST HEALTH_STATUS TEMP1 == 10") + def test_raises_if_the_exception_is_not_raised_with_kwargs(self): + with self.assertRaisesRegex( + CheckError, + r"check\(INST HEALTH_STATUS TEMP1 == 1, \{'type': 'RAW'\}\) should have raised an exception but did not", + ): + check_exception("check", "INST HEALTH_STATUS TEMP1 == 1", type="RAW") + def test_check_tolerance_raises_with_invalid_params(self): with self.assertRaisesRegex( RuntimeError, @@ -361,9 +512,9 @@ def test_waits_for_an_indefinite_time(self): def test_waits_for_a_relative_time(self): for stdout in capture_io(): - result = wait(0.2) + result = wait(0.01) self.assertTrue(isinstance(result, float)) - self.assertIn("WAIT: 0.2 seconds with actual time of 0.000", stdout.getvalue()) + self.assertIn("WAIT: 0.01 seconds with actual time of 0.000", stdout.getvalue()) def test_raises_on_a_non_numeric_time(self): with self.assertRaisesRegex(RuntimeError, "Non-numeric wait time specified"): @@ -371,36 +522,51 @@ def test_raises_on_a_non_numeric_time(self): def test_waits_for_a_tgt_pkt_item(self): for stdout in capture_io(): - result = wait("INST HEALTH_STATUS TEMP1 > 0", 5) + result = wait("INST HEALTH_STATUS TEMP1 > 0", 0.01) self.assertTrue(result) self.assertIn( "WAIT: INST HEALTH_STATUS TEMP1 > 0 success with value == 10 after waiting 0.0", stdout.getvalue(), ) - result = wait("INST HEALTH_STATUS TEMP1 < 0", 0.1, 0.1) # Last param is polling rate + result = wait("INST HEALTH_STATUS TEMP1 < 0", 0.01, 0.01) # Last param is polling rate self.assertFalse(result) self.assertIn( - "WAIT: INST HEALTH_STATUS TEMP1 < 0 failed with value == 10 after waiting 0.1", + "WAIT: INST HEALTH_STATUS TEMP1 < 0 failed with value == 10 after waiting 0.01", stdout.getvalue(), ) - result = wait("INST", "HEALTH_STATUS", "TEMP1", "> 0", 5) + result = wait("INST", "HEALTH_STATUS", "TEMP1", "> 0", 0.01) self.assertTrue(result) self.assertIn( "WAIT: INST HEALTH_STATUS TEMP1 > 0 success with value == 10 after waiting 0.0", stdout.getvalue(), ) - result = wait("INST", "HEALTH_STATUS", "TEMP1", "== 0", 0.1, 0.1) # Last param is polling rate + result = wait("INST", "HEALTH_STATUS", "TEMP1", "== 0", 0.01, 0.01) # Last param is polling rate self.assertFalse(result) self.assertIn( - "WAIT: INST HEALTH_STATUS TEMP1 == 0 failed with value == 10 after waiting 0.1", + "WAIT: INST HEALTH_STATUS TEMP1 == 0 failed with value == 10 after waiting 0.01", stdout.getvalue(), ) with self.assertRaisesRegex(RuntimeError, "Invalid number of arguments"): - wait("INST", "HEALTH_STATUS", "TEMP1", "== 0", 0.1, 0.1, 0.1) + wait("INST", "HEALTH_STATUS", "TEMP1", "== 0", 0.01, 0.01, 0.01) + + def test_waits_for_a_string_value(self): + for stdout in capture_io(): + result = wait("INST HEALTH_STATUS CCSDSSHF == 'FALSE'", 0.01) + self.assertTrue(result) + self.assertIn( + "WAIT: INST HEALTH_STATUS CCSDSSHF == 'FALSE' success with value == 'FALSE'", + stdout.getvalue(), + ) + + def test_waits_with_no_comparison(self): + for stdout in capture_io(): + result = wait("INST HEALTH_STATUS TEMP1", 0.01) + self.assertFalse(result) + self.assertIn("WARN: WAIT: INST HEALTH_STATUS TEMP1 None failed", stdout.getvalue()) def test_wait_tolerance_raises_with_invalid_params(self): with self.assertRaisesRegex( @@ -419,28 +585,28 @@ def test_wait_tolerance_raises_with_formatted_or_with_units(self): def test_waits_for_a_value_to_be_within_a_tolerance(self): for stdout in capture_io(): - result = wait_tolerance("INST", "HEALTH_STATUS", "TEMP2", 1.55, 0.1, 5, type="RAW") + result = wait_tolerance("INST", "HEALTH_STATUS", "TEMP2", 1.55, 0.1, 0.01, type="RAW") self.assertTrue(result) self.assertIn( "WAIT: INST HEALTH_STATUS TEMP2 was within range 1.45 to 1.65 with value == 1.5 after waiting 0.0", stdout.getvalue(), ) - result = wait_tolerance("INST HEALTH_STATUS TEMP2", 10.5, 0.01, 5) + result = wait_tolerance("INST HEALTH_STATUS TEMP2", 10.5, 0.01, 0.01) self.assertTrue(result) self.assertIn( "WAIT: INST HEALTH_STATUS TEMP2 was within range 10.49 to 10.51 with value == 10.5 after waiting 0.0", stdout.getvalue(), ) - result = wait_tolerance("INST HEALTH_STATUS TEMP2", 11, 0.1, 0.1) + result = wait_tolerance("INST HEALTH_STATUS TEMP2", 11, 0.1, 0.01) self.assertFalse(result) self.assertIn( - "WAIT: INST HEALTH_STATUS TEMP2 failed to be within range 10.9 to 11.1 with value == 10.5 after waiting 0.1", + "WAIT: INST HEALTH_STATUS TEMP2 failed to be within range 10.9 to 11.1 with value == 10.5 after waiting 0.01", stdout.getvalue(), ) def test_waits_that_an_array_value_is_within_a_single_tolerance(self): for stdout in capture_io(): - result = wait_tolerance("INST", "HEALTH_STATUS", "ARY", 3, 1, 5) + result = wait_tolerance("INST", "HEALTH_STATUS", "ARY", 3, 1, 0.01) self.assertTrue(result) self.assertIn( "WAIT: INST HEALTH_STATUS ARY[0] was within range 2 to 4 with value == 2", @@ -454,7 +620,7 @@ def test_waits_that_an_array_value_is_within_a_single_tolerance(self): "WAIT: INST HEALTH_STATUS ARY[2] was within range 2 to 4 with value == 4", stdout.getvalue(), ) - result = wait_tolerance("INST HEALTH_STATUS ARY", 3, 0.1, 0.1) + result = wait_tolerance("INST HEALTH_STATUS ARY", 3, 0.1, 0.01) self.assertFalse(result) self.assertIn( "INST HEALTH_STATUS ARY[0] failed to be within range 2.9 to 3.1 with value == 2", @@ -471,7 +637,7 @@ def test_waits_that_an_array_value_is_within_a_single_tolerance(self): def test_waits_that_multiple_array_values_are_within_tolerance(self): for stdout in capture_io(): - result = wait_tolerance("INST", "HEALTH_STATUS", "ARY", [2, 3, 4], 0.1, 5) + result = wait_tolerance("INST", "HEALTH_STATUS", "ARY", [2, 3, 4], 0.1, 0.01) self.assertTrue(result) self.assertIn( "WAIT: INST HEALTH_STATUS ARY[0] was within range 1.9 to 2.1 with value == 2", @@ -486,7 +652,7 @@ def test_waits_that_multiple_array_values_are_within_tolerance(self): stdout.getvalue(), ) - result = wait_tolerance("INST HEALTH_STATUS ARY", [2, 3, 4], 0.1, 5) + result = wait_tolerance("INST HEALTH_STATUS ARY", [2, 3, 4], 0.1, 0.01) self.assertTrue(result) self.assertIn( "WAIT: INST HEALTH_STATUS ARY[0] was within range 1.9 to 2.1 with value == 2", @@ -503,7 +669,7 @@ def test_waits_that_multiple_array_values_are_within_tolerance(self): def test_waits_that_an_array_value_is_within_multiple_tolerances(self): for stdout in capture_io(): - result = wait_tolerance("INST", "HEALTH_STATUS", "ARY", 3, [1, 0.1, 2], 5) + result = wait_tolerance("INST", "HEALTH_STATUS", "ARY", 3, [1, 0.1, 2], 0.01) self.assertTrue(result) self.assertIn( "WAIT: INST HEALTH_STATUS ARY[0] was within range 2 to 4 with value == 2", @@ -518,7 +684,7 @@ def test_waits_that_an_array_value_is_within_multiple_tolerances(self): stdout.getvalue(), ) - result = wait_tolerance("INST HEALTH_STATUS ARY", 3, [1, 0.1, 2], 5) + result = wait_tolerance("INST HEALTH_STATUS ARY", 3, [1, 0.1, 2], 0.01) self.assertTrue(result) self.assertIn( "WAIT: INST HEALTH_STATUS ARY[0] was within range 2 to 4 with value == 2", @@ -533,34 +699,47 @@ def test_waits_that_an_array_value_is_within_multiple_tolerances(self): stdout.getvalue(), ) + def test_wait_expression_raises_with_non_ascii(self): + with self.assertRaisesRegex(RuntimeError, "Invalid comparison to non-ascii value"): + wait_expression("True == '\xff'", 0.01) + + def test_wait_expression_returns_none_when_cancelled_and_expression_is_false(self): + global cancel + cancel = True + for stdout in capture_io(): + result = wait_expression("True == False", 0.1, 0.01) + self.assertIsNone(result) + self.assertIn("WARN: WAIT: True == False is FALSE", stdout.getvalue()) + cancel = False + def test_waits_for_an_expression(self): for stdout in capture_io(): - result = wait_expression("True == True", 5) + result = wait_expression("True == True", 0.01) self.assertTrue(result) self.assertIn( "WAIT: True == True is TRUE after waiting 0.0", stdout.getvalue(), ) - result = wait_expression("True == False", 0.1) + result = wait_expression("True == False", 0.01) self.assertFalse(result) - self.assertIn("WAIT: True == False is FALSE after waiting 0.1", stdout.getvalue()) + self.assertIn("WAIT: True == False is FALSE after waiting 0.01", stdout.getvalue()) def test_waits_for_a_logical_expression(self): for stdout in capture_io(): - result = wait_expression("'STRING' == 'STRING'", 5) + result = wait_expression("'STRING' == 'STRING'", 0.01) self.assertTrue(result) self.assertIn( "WAIT: 'STRING' == 'STRING' is TRUE after waiting 0.0", stdout.getvalue(), ) - result = wait_expression("1 == 2", 0.1) + result = wait_expression("1 == 2", 0.01) self.assertFalse(result) self.assertIn("WAIT: 1 == 2 is FALSE after waiting", stdout.getvalue()) with self.assertRaisesRegex( NameError, "Uninitialized constant STRING. Did you mean 'STRING' as a string?", ): - wait_expression("'STRING' == STRING", 5) + wait_expression("'STRING' == STRING", 0.01) def test_wait_check_raises_with_invalid_params(self): with self.assertRaisesRegex( @@ -636,19 +815,19 @@ def test_warns_when_checking_a_state_against_a_constant(self): def test_wait_check_tolerance_raises_with_formatted_or_with_units(self): with self.assertRaisesRegex(RuntimeError, r"Invalid type 'FORMATTED' for wait_check_tolerance"): - wait_check_tolerance("INST HEALTH_STATUS TEMP2 == 10.5", 0.1, 5, type="FORMATTED") + wait_check_tolerance("INST HEALTH_STATUS TEMP2 == 10.5", 0.1, 0.01, type="FORMATTED") with self.assertRaisesRegex(RuntimeError, r"Invalid type 'WITH_UNITS' for wait_check_tolerance"): - wait_check_tolerance("INST HEALTH_STATUS TEMP2 == 10.5", 0.1, 5, type="WITH_UNITS") + wait_check_tolerance("INST HEALTH_STATUS TEMP2 == 10.5", 0.1, 0.01, type="WITH_UNITS") def test_wait_checks_that_a_value_is_within_a_tolerance(self): for stdout in capture_io(): - result = wait_check_tolerance("INST", "HEALTH_STATUS", "TEMP2", 1.55, 0.1, 5, type="RAW") + result = wait_check_tolerance("INST", "HEALTH_STATUS", "TEMP2", 1.55, 0.1, 0.01, type="RAW") self.assertTrue(isinstance(result, float)) self.assertIn( "CHECK: INST HEALTH_STATUS TEMP2 was within range 1.45 to 1.65 with value == 1.5", stdout.getvalue(), ) - result = wait_check_tolerance("INST HEALTH_STATUS TEMP2", 10.5, 0.01, 5) + result = wait_check_tolerance("INST HEALTH_STATUS TEMP2", 10.5, 0.01, 0.01) self.assertTrue(isinstance(result, float)) self.assertIn( "CHECK: INST HEALTH_STATUS TEMP2 was within range 10.49 to 10.51 with value == 10.5", @@ -658,11 +837,11 @@ def test_wait_checks_that_a_value_is_within_a_tolerance(self): CheckError, r"CHECK: INST HEALTH_STATUS TEMP2 failed to be within range 10.9 to 11.1 with value == 10.5", ): - wait_check_tolerance("INST HEALTH_STATUS TEMP2", 11, 0.1, 0.1) + wait_check_tolerance("INST HEALTH_STATUS TEMP2", 11, 0.1, 0.01) def test_wait_checks_that_an_array_value_is_within_a_single_tolerance(self): for stdout in capture_io(): - result = wait_check_tolerance("INST", "HEALTH_STATUS", "ARY", 3, 1, 5) + result = wait_check_tolerance("INST", "HEALTH_STATUS", "ARY", 3, 1, 0.01) self.assertTrue(isinstance(result, float)) self.assertIn( "CHECK: INST HEALTH_STATUS ARY[0] was within range 2 to 4 with value == 2", @@ -680,17 +859,17 @@ def test_wait_checks_that_an_array_value_is_within_a_single_tolerance(self): CheckError, r"CHECK: INST HEALTH_STATUS ARY\[0\] failed to be within range 2.9 to 3.1 with value == 2", ): - wait_check_tolerance("INST HEALTH_STATUS ARY", 3, 0.1, 0.1) + wait_check_tolerance("INST HEALTH_STATUS ARY", 3, 0.1, 0.01) with self.assertRaisesRegex( CheckError, r"CHECK: INST HEALTH_STATUS ARY\[1\] was within range 2.9 to 3.1 with value == 3", ): - wait_check_tolerance("INST HEALTH_STATUS ARY", 3, 0.1, 0.1) + wait_check_tolerance("INST HEALTH_STATUS ARY", 3, 0.1, 0.01) with self.assertRaisesRegex( CheckError, r"CHECK: INST HEALTH_STATUS ARY\[2\] failed to be within range 2.9 to 3.1 with value == 4", ): - wait_check_tolerance("INST HEALTH_STATUS ARY", 3, 0.1, 0.1) + wait_check_tolerance("INST HEALTH_STATUS ARY", 3, 0.1, 0.01) def test_wait_check_tolerance_logs_instead_of_raises_when_disconnected(self): openc3.script.DISCONNECT = True @@ -712,7 +891,7 @@ def test_wait_check_tolerance_logs_instead_of_raises_when_disconnected(self): def test_wait_checks_that_multiple_array_values_are_within_tolerance(self): for stdout in capture_io(): - result = wait_check_tolerance("INST", "HEALTH_STATUS", "ARY", [2, 3, 4], 0.1, 5) + result = wait_check_tolerance("INST", "HEALTH_STATUS", "ARY", [2, 3, 4], 0.1, 0.01) self.assertTrue(isinstance(result, float)) self.assertIn( "CHECK: INST HEALTH_STATUS ARY[0] was within range 1.9 to 2.1 with value == 2", @@ -729,7 +908,7 @@ def test_wait_checks_that_multiple_array_values_are_within_tolerance(self): def test_wait_checks_that_an_array_value_is_within_multiple_tolerances(self): for stdout in capture_io(): - result = wait_check_tolerance("INST", "HEALTH_STATUS", "ARY", 3, [1, 0.1, 2], 5) + result = wait_check_tolerance("INST", "HEALTH_STATUS", "ARY", 3, [1, 0.1, 2], 0.01) self.assertTrue(isinstance(result, float)) self.assertIn( "CHECK: INST HEALTH_STATUS ARY[0] was within range 2 to 4 with value == 2", @@ -746,38 +925,38 @@ def test_wait_checks_that_an_array_value_is_within_multiple_tolerances(self): def test_waits_and_checks_that_an_expression_is_true(self): for stdout in capture_io(): - result = wait_check_expression("True == True", 5) + result = wait_check_expression("True == True", 0.01) self.assertTrue(isinstance(result, float)) self.assertIn("CHECK: True == True is TRUE", stdout.getvalue()) with self.assertRaisesRegex(CheckError, "CHECK: True == False is FALSE"): - wait_check_expression("True == False", 0.1) + wait_check_expression("True == False", 0.01) def test_wait_check_expression_logs_instead_of_raises_when_disconnected(self): openc3.script.DISCONNECT = True for stdout in capture_io(): - result = wait_check_expression("True == False", 5) + result = wait_check_expression("True == False", 0.01) self.assertTrue(isinstance(result, float)) self.assertIn("CHECK: True == False is FALSE", stdout.getvalue()) openc3.script.DISCONNECT = False def test_waits_and_checks_a_logical_expression(self): for stdout in capture_io(): - result = wait_check_expression("'STRING' == 'STRING'", 5) + result = wait_check_expression("'STRING' == 'STRING'", 0.01) self.assertTrue(isinstance(result, float)) self.assertIn("CHECK: 'STRING' == 'STRING' is TRUE", stdout.getvalue()) with self.assertRaisesRegex(CheckError, "CHECK: 1 == 2 is FALSE"): - wait_check_expression("1 == 2", 0.1) + wait_check_expression("1 == 2", 0.01) with self.assertRaisesRegex( NameError, "Uninitialized constant STRING. Did you mean 'STRING' as a string?", ): - wait_check_expression("'STRING' == STRING", 0.1) + wait_check_expression("'STRING' == STRING", 0.01) def test_wait_packet_prints_warning_if_packet_not_received(self): global count count = False for stdout in capture_io(): - result = wait_packet("INST", "HEALTH_STATUS", 1, 0.5) + result = wait_packet("INST", "HEALTH_STATUS", 1, 0.1) self.assertFalse(result) self.assertIn( "WAIT: INST HEALTH_STATUS expected to be received 1 times but only received 0 times", @@ -790,14 +969,14 @@ def test_wait_packet_prints_success_if_the_packet_is_received(self): count = True for stdout in capture_io(): cancel = True - result = wait_packet("INST", "HEALTH_STATUS", 5, 0.5) + result = wait_packet("INST", "HEALTH_STATUS", 5, 0.1) self.assertFalse(result) self.assertIn( "WAIT: INST HEALTH_STATUS expected to be received 5 times", stdout.getvalue(), ) cancel = False - result = wait_packet("INST", "HEALTH_STATUS", 5, 0.5) + result = wait_packet("INST", "HEALTH_STATUS", 5, 0.1) self.assertTrue(result) self.assertIn( "WAIT: INST HEALTH_STATUS received 5 times after waiting", @@ -811,7 +990,7 @@ def test_wait_check_packet_raises_a_check_error_if_packet_not_received(self): CheckError, "CHECK: INST HEALTH_STATUS expected to be received 1 times but only received 0 times", ): - wait_check_packet("INST", "HEALTH_STATUS", 1, 0.5) + wait_check_packet("INST", "HEALTH_STATUS", 1, 0.1) def test_wait_check_packet_logs_instead_of_raises_if_disconnected(self): openc3.script.DISCONNECT = True @@ -838,7 +1017,7 @@ def test_wait_check_packet_prints_success_if_the_packet_is_received(self): ): wait_check_packet("INST", "HEALTH_STATUS", 5, 0.0) cancel = False - result = wait_check_packet("INST", "HEALTH_STATUS", 5, 0.5) + result = wait_check_packet("INST", "HEALTH_STATUS", 5, 0.1) self.assertTrue(isinstance(result, float)) self.assertIn( "CHECK: INST HEALTH_STATUS received 5 times after waiting", diff --git a/openc3/python/test/utilities/test_extract.py b/openc3/python/test/utilities/test_extract.py new file mode 100644 index 0000000000..cfed3da20f --- /dev/null +++ b/openc3/python/test/utilities/test_extract.py @@ -0,0 +1,209 @@ +# Copyright 2026 OpenC3, Inc. +# All Rights Reserved. +# +# 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 LICENSE.md for more details. + +# This file may also be used under the terms of a commercial license +# if purchased from OpenC3, Inc. + +import pytest + +from openc3.utilities.extract import ( + add_cmd_parameter, + extract_fields_from_check_text, + extract_fields_from_cmd_text, + extract_fields_from_set_tlm_text, + extract_fields_from_tlm_text, + extract_operator_and_operand_from_comparison, +) + + +class TestAddCmdParameter: + def test_removes_quotes_and_preserves_quoted_strings(self): + cmd_params = {} + add_cmd_parameter("TEST", '"3"', cmd_params) + assert cmd_params["TEST"] == "3" + + def test_converts_unquoted_strings_to_correct_value_type(self): + cmd_params = {} + add_cmd_parameter("TEST", "3", cmd_params) + assert cmd_params["TEST"] == 3 + add_cmd_parameter("TEST2", "3.0", cmd_params) + assert cmd_params["TEST2"] == 3.0 + add_cmd_parameter("TEST3", "0xA", cmd_params) + assert cmd_params["TEST3"] == 0xA + add_cmd_parameter("TEST4", "3e3", cmd_params) + assert cmd_params["TEST4"] == 3e3 + add_cmd_parameter("TEST5", "Ryan", cmd_params) + assert cmd_params["TEST5"] == "Ryan" + add_cmd_parameter("TEST6", "3 4", cmd_params) + assert cmd_params["TEST6"] == "3 4" + + +class TestExtractFieldsFromCmdText: + def test_complains_about_empty_strings(self): + with pytest.raises(RuntimeError, match="text must not be empty"): + extract_fields_from_cmd_text("") + + def test_complains_about_strings_ending_with_with_but_no_params(self): + with pytest.raises(RuntimeError, match="must be followed by parameters"): + extract_fields_from_cmd_text("TEST COMMAND with") + with pytest.raises(RuntimeError, match="must be followed by parameters"): + extract_fields_from_cmd_text("TEST COMMAND with ") + + def test_complains_if_target_or_packet_name_missing(self): + with pytest.raises(RuntimeError, match="Both Target Name and Command Name must be given"): + extract_fields_from_cmd_text("TEST") + + def test_complains_if_too_many_words_before_with(self): + with pytest.raises(RuntimeError, match="Only Target Name and Command Name must be given"): + extract_fields_from_cmd_text("TEST TEST TEST") + + def test_complains_if_key_value_pairs_are_malformed(self): + with pytest.raises(RuntimeError, match="Missing value for last command parameter"): + extract_fields_from_cmd_text("TEST TEST with KEY VALUE, KEY VALUE, VALUE") + with pytest.raises(RuntimeError, match="Missing comma in command parameters"): + extract_fields_from_cmd_text("TEST TEST with KEY VALUE KEY VALUE") + with pytest.raises(RuntimeError, match="Missing comma in command parameters"): + extract_fields_from_cmd_text("TEST TEST with KEY VALUE KEY, KEY VALUE") + with pytest.raises(RuntimeError, match="Missing value for last command parameter"): + extract_fields_from_cmd_text("TEST TEST with KEY VALUE, KEY") + + def test_parses_commands_correctly(self): + result = extract_fields_from_cmd_text("TARGET PACKET with KEY1 VALUE1, KEY2 2, KEY3 '3', KEY4 4.0") + assert result == ("TARGET", "PACKET", {"KEY1": "VALUE1", "KEY2": 2, "KEY3": "3", "KEY4": 4.0}) + + def test_handles_multiple_array_parameters(self): + result = extract_fields_from_cmd_text("TARGET PACKET with KEY1 [1,2,3,4], KEY2 2, KEY3 '3', KEY4 [5, 6, 7, 8]") + assert result == ("TARGET", "PACKET", {"KEY1": [1, 2, 3, 4], "KEY2": 2, "KEY3": "3", "KEY4": [5, 6, 7, 8]}) + + result = extract_fields_from_cmd_text( + "TARGET PACKET with KEY1 [1,2,3,4], KEY2 2, KEY3 '3', KEY4 ['1', '2', '3', '4']" + ) + assert result == ( + "TARGET", + "PACKET", + {"KEY1": [1, 2, 3, 4], "KEY2": 2, "KEY3": "3", "KEY4": ["1", "2", "3", "4"]}, + ) + + +class TestExtractFieldsFromTlmText: + def test_requires_exactly_target_packet_item(self): + with pytest.raises(RuntimeError, match="Telemetry Item must be specified as"): + extract_fields_from_tlm_text("") + with pytest.raises(RuntimeError, match="Telemetry Item must be specified as"): + extract_fields_from_tlm_text("TARGET") + with pytest.raises(RuntimeError, match="Telemetry Item must be specified as"): + extract_fields_from_tlm_text("TARGET PACKET") + with pytest.raises(RuntimeError, match="Telemetry Item must be specified as"): + extract_fields_from_tlm_text("TARGET PACKET ") + with pytest.raises(RuntimeError, match="Telemetry Item must be specified as"): + extract_fields_from_tlm_text("TARGET PACKET ITEM OTHER") + + def test_parses_telemetry_names_correctly(self): + assert extract_fields_from_tlm_text("TARGET PACKET ITEM") == ("TARGET", "PACKET", "ITEM") + assert extract_fields_from_tlm_text(" TARGET PACKET ITEM ") == ( + "TARGET", + "PACKET", + "ITEM", + ) + + +class TestExtractFieldsFromSetTlmText: + def test_complains_if_formatted_incorrectly(self): + with pytest.raises(RuntimeError, match="Set Telemetry Item must be specified as"): + extract_fields_from_set_tlm_text("") + with pytest.raises(RuntimeError, match="Set Telemetry Item must be specified as"): + extract_fields_from_set_tlm_text("TARGET") + with pytest.raises(RuntimeError, match="Set Telemetry Item must be specified as"): + extract_fields_from_set_tlm_text("TARGET PACKET") + with pytest.raises(RuntimeError, match="Set Telemetry Item must be specified as"): + extract_fields_from_set_tlm_text("TARGET PACKET ITEM") + with pytest.raises(RuntimeError, match="Set Telemetry Item must be specified as"): + extract_fields_from_set_tlm_text("TARGET PACKET ITEM=") + with pytest.raises(RuntimeError, match="Set Telemetry Item must be specified as"): + extract_fields_from_set_tlm_text("TARGET PACKET ITEM= ") + with pytest.raises(RuntimeError, match="Set Telemetry Item must be specified as"): + extract_fields_from_set_tlm_text("TARGET PACKET ITEM =") + with pytest.raises(RuntimeError, match="Set Telemetry Item must be specified as"): + extract_fields_from_set_tlm_text("TARGET PACKET ITEM = ") + + def test_parses_set_tlm_text_correctly(self): + assert extract_fields_from_set_tlm_text("TARGET PACKET ITEM= 5") == ("TARGET", "PACKET", "ITEM", 5) + assert extract_fields_from_set_tlm_text("TARGET PACKET ITEM = 5") == ("TARGET", "PACKET", "ITEM", 5) + assert extract_fields_from_set_tlm_text("TARGET PACKET ITEM =5") == ("TARGET", "PACKET", "ITEM", 5) + assert extract_fields_from_set_tlm_text("TARGET PACKET ITEM=5") == ("TARGET", "PACKET", "ITEM", 5) + assert extract_fields_from_set_tlm_text("TARGET PACKET ITEM = 5.0") == ("TARGET", "PACKET", "ITEM", 5.0) + assert extract_fields_from_set_tlm_text("TARGET PACKET ITEM = Ryan") == ("TARGET", "PACKET", "ITEM", "Ryan") + assert extract_fields_from_set_tlm_text("TARGET PACKET ITEM = [1,2,3]") == ( + "TARGET", + "PACKET", + "ITEM", + [1, 2, 3], + ) + + +class TestExtractFieldsFromCheckText: + def test_complains_if_formatted_incorrectly(self): + with pytest.raises((RuntimeError, ValueError), match="Check improperly specified"): + extract_fields_from_check_text("") + with pytest.raises((RuntimeError, ValueError), match="Check improperly specified"): + extract_fields_from_check_text("TARGET") + with pytest.raises((RuntimeError, ValueError), match="Check improperly specified"): + extract_fields_from_check_text("TARGET PACKET") + + def test_supports_no_comparison(self): + assert extract_fields_from_check_text("TARGET PACKET ITEM") == ("TARGET", "PACKET", "ITEM", None) + assert extract_fields_from_check_text("TARGET PACKET ITEM ") == ("TARGET", "PACKET", "ITEM", None) + + def test_supports_comparisons(self): + assert extract_fields_from_check_text("TARGET PACKET ITEM == 5") == ("TARGET", "PACKET", "ITEM", "== 5") + assert extract_fields_from_check_text("TARGET PACKET ITEM > 5") == ("TARGET", "PACKET", "ITEM", "> 5") + assert extract_fields_from_check_text("TARGET PACKET ITEM < 5") == ("TARGET", "PACKET", "ITEM", "< 5") + + def test_supports_target_packet_items_named_the_same(self): + assert extract_fields_from_check_text("TEST TEST TEST == 5") == ("TEST", "TEST", "TEST", "== 5") + + def test_complains_about_trying_to_do_an_equal_comparison(self): + with pytest.raises(RuntimeError, match="ERROR: Use"): + extract_fields_from_check_text("TARGET PACKET ITEM = 5") + + def test_handles_spaces_with_quotes_correctly(self): + assert extract_fields_from_check_text('TARGET PACKET ITEM == "This is a test"') == ( + "TARGET", + "PACKET", + "ITEM", + '== "This is a test"', + ) + assert extract_fields_from_check_text("TARGET PACKET ITEM == 'This is a test '") == ( + "TARGET", + "PACKET", + "ITEM", + "== 'This is a test '", + ) + + +class TestExtractOperatorAndOperandFromComparison: + def test_parses_string_operands(self): + assert extract_operator_and_operand_from_comparison("== 'foo'") == ("==", "foo") + + def test_parses_number_operands(self): + assert extract_operator_and_operand_from_comparison("== 1") == ("==", 1) + + def test_parses_list_operands(self): + assert extract_operator_and_operand_from_comparison("in [1, 2, 3]") == ("in", [1, 2, 3]) + + def test_parses_none_operands(self): + assert extract_operator_and_operand_from_comparison("== None") == ("==", None) + + def test_complains_about_invalid_operators(self): + with pytest.raises(RuntimeError, match="ERROR: Invalid"): + extract_operator_and_operand_from_comparison("^ 'foo'") + + def test_complains_about_unparsable_operands(self): + with pytest.raises(RuntimeError, match="ERROR: Unable"): + extract_operator_and_operand_from_comparison("== foo") + diff --git a/openc3/python/uv.lock b/openc3/python/uv.lock index 4bc7fdb14b..5eff7bcb76 100644 --- a/openc3/python/uv.lock +++ b/openc3/python/uv.lock @@ -753,7 +753,7 @@ wheels = [ [[package]] name = "openc3" -version = "7.0.0rc4" +version = "7.0.1b0" source = { editable = "." } dependencies = [ { name = "boto3" }, diff --git a/openc3/spec/script/api_shared_spec.rb b/openc3/spec/script/api_shared_spec.rb index e17376e480..8bfe2f75c4 100644 --- a/openc3/spec/script/api_shared_spec.rb +++ b/openc3/spec/script/api_shared_spec.rb @@ -178,6 +178,62 @@ def openc3_script_sleep(_sleep_time = nil) end expect { check("INST HEALTH_STATUS CCSDSSHF == FALSE") }.to raise_error(NameError, "Uninitialized constant FALSE. Did you mean 'FALSE' as a string?") end + + it "raises with an invalid operator" do + expect { check("INST HEALTH_STATUS TEMP1 === 1") }.to raise_error(RuntimeError, /ERROR: Invalid operator: '==='/) + expect { check("INST HEALTH_STATUS TEMP1 not 1") }.to raise_error(RuntimeError, /ERROR: Invalid operator: 'not'/) + expect { check("INST HEALTH_STATUS TEMP1 and 1") }.to raise_error(RuntimeError, /ERROR: Invalid operator: 'and'/) + end + + it "raises with an operator but no operand" do + expect { check("INST HEALTH_STATUS TEMP1 >") }.to raise_error(RuntimeError, /ERROR: Invalid comparison, must specify an operand: >/) + expect { check("INST HEALTH_STATUS TEMP1 ==") }.to raise_error(RuntimeError, /ERROR: Invalid comparison, must specify an operand: ==/) + end + + it "raises with invalid comparison for types" do + # Numeric value compared against nil operand + expect { check("INST HEALTH_STATUS TEMP1 > nil") }.to raise_error(CheckError, /Invalid comparison for types/) + expect { check("INST HEALTH_STATUS TEMP1 < nil") }.to raise_error(CheckError, /Invalid comparison for types/) + expect { check("INST HEALTH_STATUS TEMP1 >= nil") }.to raise_error(CheckError, /Invalid comparison for types/) + expect { check("INST HEALTH_STATUS TEMP1 <= nil") }.to raise_error(CheckError, /Invalid comparison for types/) + # Nil value compared against numeric operand + @count = false + expect { check("INST HEALTH_STATUS RECEIVED_COUNT > 1") }.to raise_error(CheckError, /Invalid comparison for types/) + expect { check("INST HEALTH_STATUS RECEIVED_COUNT < 1") }.to raise_error(CheckError, /Invalid comparison for types/) + @count = true + # Array value compared with ordering operators + expect { check("INST HEALTH_STATUS ARY > 1") }.to raise_error(CheckError, /Invalid comparison for types/) + expect { check("INST HEALTH_STATUS ARY < 1") }.to raise_error(CheckError, /Invalid comparison for types/) + end + + it "logs invalid comparison for types when disconnected" do + $disconnect = true + capture_io do |stdout| + check("INST HEALTH_STATUS TEMP1 > nil") + expect(stdout.string).to match(/ERROR: Invalid comparison for types/) + end + $disconnect = false + end + + it "checks nil value with equality operators" do + @count = false + # Nil value with == and != should go through eval, not raise invalid comparison + capture_io do |stdout| + check("INST HEALTH_STATUS RECEIVED_COUNT == nil") + expect(stdout.string).to match(/CHECK: INST HEALTH_STATUS RECEIVED_COUNT == nil success with value == nil/) + end + expect { check("INST HEALTH_STATUS RECEIVED_COUNT != nil") }.to raise_error(CheckError, /CHECK: INST HEALTH_STATUS RECEIVED_COUNT != nil failed with value == nil/) + @count = true + end + + it "prints nil for value with no comparison" do + @count = false + capture_io do |stdout| + check("INST HEALTH_STATUS RECEIVED_COUNT") + expect(stdout.string).to match(/CHECK: INST HEALTH_STATUS RECEIVED_COUNT == nil/) + end + @count = true + end end describe "check_raw, check_formatted" do @@ -336,9 +392,9 @@ def openc3_script_sleep(_sleep_time = nil) it "waits for a relative time" do capture_io do |stdout| - result = wait(5) + result = wait(0.01) expect(result).to be_a Float - expect(stdout.string).to match(/WAIT: 5 seconds with actual time of .* seconds/) + expect(stdout.string).to match(/WAIT: 0.01 seconds with actual time of .* seconds/) end end @@ -348,24 +404,35 @@ def openc3_script_sleep(_sleep_time = nil) it "waits for a TGT PKT ITEM" do capture_io do |stdout| - result = wait("INST HEALTH_STATUS TEMP1 > 0", 5) + result = wait("INST HEALTH_STATUS TEMP1 > 0", 0.01) expect(result).to be true expect(stdout.string).to match(/WAIT: INST HEALTH_STATUS TEMP1 > 0 success with value == 10 after waiting .* seconds/) - result = wait("INST HEALTH_STATUS TEMP1 < 0", 0.1, 0.1) # Last param is polling rate + result = wait("INST HEALTH_STATUS TEMP1 < 0", 0.01, 0.01) # Last param is polling rate expect(result).to be false expect(stdout.string).to match(/WAIT: INST HEALTH_STATUS TEMP1 < 0 failed with value == 10 after waiting .* seconds/) - result = wait("INST", "HEALTH_STATUS", "TEMP1", "> 0", 5) + result = wait("INST", "HEALTH_STATUS", "TEMP1", "> 0", 0.01) expect(result).to be true expect(stdout.string).to match(/WAIT: INST HEALTH_STATUS TEMP1 > 0 success with value == 10 after waiting .* seconds/) - result = wait("INST", "HEALTH_STATUS", "TEMP1", "== 0", 0.1, 0.1) # Last param is polling rate + result = wait("INST", "HEALTH_STATUS", "TEMP1", "== 0", 0.01, 0.01) # Last param is polling rate expect(result).to be false expect(stdout.string).to match(/WAIT: INST HEALTH_STATUS TEMP1 == 0 failed with value == 10 after waiting .* seconds/) end - expect { wait("INST", "HEALTH_STATUS", "TEMP1", "== 0", 0.1, 0.1, 0.1) }.to raise_error(/Invalid number of arguments/) + expect { wait("INST", "HEALTH_STATUS", "TEMP1", "== 0", 0.01, 0.01, 0.01) }.to raise_error(/Invalid number of arguments/) + end + + it "returns true when cancelled and the eval condition is met" do + @sleep_cancel = true + capture_io do |stdout| + # RECEIVED_COUNT increments each call: 1st call returns 1 (fails >= 2), sleep cancels, 2nd call returns 2 (passes) + result = wait("INST HEALTH_STATUS RECEIVED_COUNT >= 2", 0.1, 0.01) + expect(result).to be true + expect(stdout.string).to match(/WAIT: INST HEALTH_STATUS RECEIVED_COUNT >= 2 success/) + end + @sleep_cancel = false end end @@ -383,26 +450,42 @@ def openc3_script_sleep(_sleep_time = nil) it "waits for a value to be within a tolerance" do capture_io do |stdout| - result = wait_tolerance("INST", "HEALTH_STATUS", "TEMP2", 1.55, 0.1, 5, type: :RAW) + result = wait_tolerance("INST", "HEALTH_STATUS", "TEMP2", 1.55, 0.1, 0.01, type: :RAW) expect(result).to be true expect(stdout.string).to match(/WAIT: INST HEALTH_STATUS TEMP2 was within range 1.45 to 1.65\d+ with value == 1.5/) - result = wait_tolerance("INST HEALTH_STATUS TEMP2", 10.5, 0.01, 5) + result = wait_tolerance("INST HEALTH_STATUS TEMP2", 10.5, 0.01, 0.01) expect(result).to be true expect(stdout.string).to include("WAIT: INST HEALTH_STATUS TEMP2 was within range 10.49 to 10.51 with value == 10.5") - result = wait_tolerance("INST HEALTH_STATUS TEMP2", 11, 0.1, 0.1) + result = wait_tolerance("INST HEALTH_STATUS TEMP2", 11, 0.1, 0.01) expect(result).to be false expect(stdout.string).to include("WAIT: INST HEALTH_STATUS TEMP2 failed to be within range 10.9 to 11.1 with value == 10.5") end end + it "accepts a polling_rate with the string form" do + capture_io do |stdout| + result = wait_tolerance("INST HEALTH_STATUS TEMP2", 10.5, 0.01, 0.01, 0.01) + expect(result).to be true + expect(stdout.string).to include("WAIT: INST HEALTH_STATUS TEMP2 was within range 10.49 to 10.51 with value == 10.5") + end + end + + it "accepts a polling_rate with the explicit form" do + capture_io do |stdout| + result = wait_tolerance("INST", "HEALTH_STATUS", "TEMP2", 1.55, 0.1, 0.01, 0.01, type: :RAW) + expect(result).to be true + expect(stdout.string).to match(/WAIT: INST HEALTH_STATUS TEMP2 was within range 1.45 to 1.65\d+ with value == 1.5/) + end + end + it "checks that an array value is within a single tolerance" do capture_io do |stdout| - result = wait_tolerance("INST", "HEALTH_STATUS", "ARY", 3, 1, 5) + result = wait_tolerance("INST", "HEALTH_STATUS", "ARY", 3, 1, 0.01) expect(result).to be true expect(stdout.string).to include("WAIT: INST HEALTH_STATUS ARY[0] was within range 2 to 4 with value == 2") expect(stdout.string).to include("WAIT: INST HEALTH_STATUS ARY[1] was within range 2 to 4 with value == 3") expect(stdout.string).to include("WAIT: INST HEALTH_STATUS ARY[2] was within range 2 to 4 with value == 4") - result = wait_tolerance("INST HEALTH_STATUS ARY", 3, 0.1, 0.1) + result = wait_tolerance("INST HEALTH_STATUS ARY", 3, 0.1, 0.01) expect(result).to be false expect(stdout.string).to include("INST HEALTH_STATUS ARY[0] failed to be within range 2.9 to 3.1 with value == 2") expect(stdout.string).to include("INST HEALTH_STATUS ARY[1] was within range 2.9 to 3.1 with value == 3") @@ -412,13 +495,13 @@ def openc3_script_sleep(_sleep_time = nil) it "checks that multiple array values are within tolerance" do capture_io do |stdout| - result = wait_tolerance("INST", "HEALTH_STATUS", "ARY", [2, 3, 4], 0.1, 5) + result = wait_tolerance("INST", "HEALTH_STATUS", "ARY", [2, 3, 4], 0.1, 0.01) expect(result).to be true expect(stdout.string).to include("WAIT: INST HEALTH_STATUS ARY[0] was within range 1.9 to 2.1 with value == 2") expect(stdout.string).to include("WAIT: INST HEALTH_STATUS ARY[1] was within range 2.9 to 3.1 with value == 3") expect(stdout.string).to include("WAIT: INST HEALTH_STATUS ARY[2] was within range 3.9 to 4.1 with value == 4") - result = wait_tolerance("INST HEALTH_STATUS ARY", [2, 3, 4], 0.1, 5) + result = wait_tolerance("INST HEALTH_STATUS ARY", [2, 3, 4], 0.1, 0.01) expect(result).to be true expect(stdout.string).to include("WAIT: INST HEALTH_STATUS ARY[0] was within range 1.9 to 2.1 with value == 2") expect(stdout.string).to include("WAIT: INST HEALTH_STATUS ARY[1] was within range 2.9 to 3.1 with value == 3") @@ -428,13 +511,13 @@ def openc3_script_sleep(_sleep_time = nil) it "checks that an array value is within multiple tolerances" do capture_io do |stdout| - result = wait_tolerance("INST", "HEALTH_STATUS", "ARY", 3, [1, 0.1, 2], 5) + result = wait_tolerance("INST", "HEALTH_STATUS", "ARY", 3, [1, 0.1, 2], 0.01) expect(result).to be true expect(stdout.string).to include("WAIT: INST HEALTH_STATUS ARY[0] was within range 2 to 4 with value == 2") expect(stdout.string).to include("WAIT: INST HEALTH_STATUS ARY[1] was within range 2.9 to 3.1 with value == 3") expect(stdout.string).to include("WAIT: INST HEALTH_STATUS ARY[2] was within range 1 to 5 with value == 4") - result = wait_tolerance("INST HEALTH_STATUS ARY", 3, [1, 0.1, 2], 5) + result = wait_tolerance("INST HEALTH_STATUS ARY", 3, [1, 0.1, 2], 0.01) expect(result).to be true expect(stdout.string).to include("WAIT: INST HEALTH_STATUS ARY[0] was within range 2 to 4 with value == 2") expect(stdout.string).to include("WAIT: INST HEALTH_STATUS ARY[1] was within range 2.9 to 3.1 with value == 3") @@ -449,10 +532,10 @@ def openc3_script_sleep(_sleep_time = nil) it "waits for an expression" do @sleep_cancel = cancel capture_io do |stdout| - result = wait_expression("true == true", 5) + result = wait_expression("true == true", 0.01) expect(result).to be true expect(stdout.string).to match(/WAIT: true == true is TRUE after waiting .* seconds/) - result = wait_expression("true == false", 0.1) + result = wait_expression("true == false", 0.01) expect(result).to be false expect(stdout.string).to match(/WAIT: true == false is FALSE after waiting .* seconds/) end @@ -460,16 +543,27 @@ def openc3_script_sleep(_sleep_time = nil) end end + it "succeeds when cancelled and the expression becomes true" do + @sleep_cancel = true + counter = 0 + capture_io do |stdout| + result = wait_expression("(counter += 1) >= 2", 0.1, 0.01, binding) + expect(result).to be true + expect(stdout.string).to match(/WAIT: \(counter \+= 1\) >= 2 is TRUE/) + end + @sleep_cancel = false + end + it "checks a logical expression" do capture_io do |stdout| - result = wait_expression("'STRING' == 'STRING'", 5) + result = wait_expression("'STRING' == 'STRING'", 0.01) expect(result).to be true expect(stdout.string).to match(/WAIT: 'STRING' == 'STRING' is TRUE after waiting .* seconds/) - result = wait_expression("1 == 2", 0.1) + result = wait_expression("1 == 2", 0.01) expect(result).to be false expect(stdout.string).to match(/WAIT: 1 == 2 is FALSE after waiting .* seconds/) end - expect { wait_expression("'STRING' == STRING", 5) }.to raise_error(NameError, "Uninitialized constant STRING. Did you mean 'STRING' as a string?") + expect { wait_expression("'STRING' == STRING", 0.01) }.to raise_error(NameError, "Uninitialized constant STRING. Did you mean 'STRING' as a string?") end end @@ -490,6 +584,14 @@ def openc3_script_sleep(_sleep_time = nil) expect { wait_check("INST HEALTH_STATUS TEMP1 > 100", 0.01) }.to raise_error(/CHECK: INST HEALTH_STATUS TEMP1 > 100 failed with value == 10/) end + it "uses default polling_rate with the explicit form" do + capture_io do |stdout| + result = wait_check("INST", "HEALTH_STATUS", "TEMP1", "> 1", 0.01) + expect(result).to be_a Float + expect(stdout.string).to include('CHECK: INST HEALTH_STATUS TEMP1 > 1 success with value == 10') + end + end + [true, false].each do |cancel| context "with wait cancelled #{cancel}" do it "handles a block" do @@ -512,6 +614,31 @@ def openc3_script_sleep(_sleep_time = nil) end end + it "succeeds when cancelled and the eval condition is met" do + @sleep_cancel = true + capture_io do |stdout| + # RECEIVED_COUNT increments each call: 1st returns 1 (fails >= 2), sleep cancels, 2nd returns 2 (passes) + result = wait_check("INST HEALTH_STATUS RECEIVED_COUNT >= 2", 0.1, 0.01) + expect(result).to be_a Float + expect(stdout.string).to include('CHECK: INST HEALTH_STATUS RECEIVED_COUNT >= 2 success') + end + @sleep_cancel = false + end + + it "succeeds when cancelled and the block condition is met" do + @sleep_cancel = true + call_count = 0 + capture_io do |stdout| + result = wait_check("INST HEALTH_STATUS TEMP1", 0.1, 0.01) do |value| + call_count += 1 + call_count >= 2 # Fail on first call, succeed on second (after cancel) + end + expect(result).to be_a Float + expect(stdout.string).to include('CHECK: INST HEALTH_STATUS TEMP1 success with value == 10') + end + @sleep_cancel = false + end + it "logs instead of raises when disconnected" do $disconnect = true capture_io do |stdout| @@ -539,44 +666,44 @@ def openc3_script_sleep(_sleep_time = nil) describe "wait_check_tolerance" do it "raises with :FORMATTED or :WITH_UNITS" do - expect { wait_check_tolerance("INST HEALTH_STATUS TEMP2 == 10.5", 0.1, 5, type: :FORMATTED) }.to raise_error("Invalid type 'FORMATTED' for wait_check_tolerance") - expect { wait_check_tolerance("INST HEALTH_STATUS TEMP2 == 10.5", 0.1, 5, type: :WITH_UNITS) }.to raise_error("Invalid type 'WITH_UNITS' for wait_check_tolerance") + expect { wait_check_tolerance("INST HEALTH_STATUS TEMP2 == 10.5", 0.1, 0.01, type: :FORMATTED) }.to raise_error("Invalid type 'FORMATTED' for wait_check_tolerance") + expect { wait_check_tolerance("INST HEALTH_STATUS TEMP2 == 10.5", 0.1, 0.01, type: :WITH_UNITS) }.to raise_error("Invalid type 'WITH_UNITS' for wait_check_tolerance") end it "checks that a value is within a tolerance" do capture_io do |stdout| - result = wait_check_tolerance("INST", "HEALTH_STATUS", "TEMP2", 1.55, 0.1, 5, type: :RAW) + result = wait_check_tolerance("INST", "HEALTH_STATUS", "TEMP2", 1.55, 0.1, 0.01, type: :RAW) expect(result).to be_a Float expect(stdout.string).to match(/CHECK: INST HEALTH_STATUS TEMP2 was within range 1.45 to 1.65\d+ with value == 1.5/) - result = wait_check_tolerance("INST HEALTH_STATUS TEMP2", 10.5, 0.01, 5) + result = wait_check_tolerance("INST HEALTH_STATUS TEMP2", 10.5, 0.01, 0.01) expect(result).to be_a Float expect(stdout.string).to match(/CHECK: INST HEALTH_STATUS TEMP2 was within range 10.49 to 10.51 with value == 10.5/) end - expect { wait_check_tolerance("INST HEALTH_STATUS TEMP2", 11, 0.1, 0.1) }.to raise_error(CheckError, /CHECK: INST HEALTH_STATUS TEMP2 failed to be within range 10.9 to 11.1 with value == 10.5/) + expect { wait_check_tolerance("INST HEALTH_STATUS TEMP2", 11, 0.1, 0.01) }.to raise_error(CheckError, /CHECK: INST HEALTH_STATUS TEMP2 failed to be within range 10.9 to 11.1 with value == 10.5/) end it "checks that an array value is within a single tolerance" do capture_io do |stdout| - result = wait_check_tolerance("INST", "HEALTH_STATUS", "ARY", 3, 1, 5) + result = wait_check_tolerance("INST", "HEALTH_STATUS", "ARY", 3, 1, 0.01) expect(result).to be_a Float expect(stdout.string).to include("CHECK: INST HEALTH_STATUS ARY[0] was within range 2 to 4 with value == 2") expect(stdout.string).to include("CHECK: INST HEALTH_STATUS ARY[1] was within range 2 to 4 with value == 3") expect(stdout.string).to include("CHECK: INST HEALTH_STATUS ARY[2] was within range 2 to 4 with value == 4") end - expect { wait_check_tolerance("INST HEALTH_STATUS ARY", 3, 0.1, 0.1) }.to raise_error(/INST HEALTH_STATUS ARY\[0\] failed to be within range 2.9 to 3.1 with value == 2/) - expect { wait_check_tolerance("INST HEALTH_STATUS ARY", 3, 0.1, 0.1) }.to raise_error(/INST HEALTH_STATUS ARY\[1\] was within range 2.9 to 3.1 with value == 3/) - expect { wait_check_tolerance("INST HEALTH_STATUS ARY", 3, 0.1, 0.1) }.to raise_error(/INST HEALTH_STATUS ARY\[2\] failed to be within range 2.9 to 3.1 with value == 4/) + expect { wait_check_tolerance("INST HEALTH_STATUS ARY", 3, 0.1, 0.01) }.to raise_error(/INST HEALTH_STATUS ARY\[0\] failed to be within range 2.9 to 3.1 with value == 2/) + expect { wait_check_tolerance("INST HEALTH_STATUS ARY", 3, 0.1, 0.01) }.to raise_error(/INST HEALTH_STATUS ARY\[1\] was within range 2.9 to 3.1 with value == 3/) + expect { wait_check_tolerance("INST HEALTH_STATUS ARY", 3, 0.1, 0.01) }.to raise_error(/INST HEALTH_STATUS ARY\[2\] failed to be within range 2.9 to 3.1 with value == 4/) end it "logs instead of raises when disconnected" do $disconnect = true capture_io do |stdout| - result = wait_check_tolerance("INST HEALTH_STATUS TEMP2", 11, 0.1, 0.1) + result = wait_check_tolerance("INST HEALTH_STATUS TEMP2", 11, 0.1, 0.01) expect(result).to be_a Float expect(stdout.string).to match(/CHECK: INST HEALTH_STATUS TEMP2 failed to be within range 10.9 to 11.1 with value == 10.5/) end capture_io do |stdout| - result = wait_check_tolerance("INST HEALTH_STATUS ARY", 3, 0.1, 0.1) + result = wait_check_tolerance("INST HEALTH_STATUS ARY", 3, 0.1, 0.01) expect(result).to be_a Float expect(stdout.string).to match(/CHECK: INST HEALTH_STATUS ARY\[0\] failed to be within range 2.9 to 3.1 with value == 2/) end @@ -585,7 +712,7 @@ def openc3_script_sleep(_sleep_time = nil) it "checks that multiple array values are within tolerance" do capture_io do |stdout| - result = wait_check_tolerance("INST", "HEALTH_STATUS", "ARY", [2, 3, 4], 0.1, 5) + result = wait_check_tolerance("INST", "HEALTH_STATUS", "ARY", [2, 3, 4], 0.1, 0.01) expect(result).to be_a Float expect(stdout.string).to include("CHECK: INST HEALTH_STATUS ARY[0] was within range 1.9 to 2.1 with value == 2") expect(stdout.string).to include("CHECK: INST HEALTH_STATUS ARY[1] was within range 2.9 to 3.1 with value == 3") @@ -595,7 +722,7 @@ def openc3_script_sleep(_sleep_time = nil) it "checks that an array value is within multiple tolerances" do capture_io do |stdout| - result = wait_check_tolerance("INST", "HEALTH_STATUS", "ARY", 3, [1, 0.1, 2], 5) + result = wait_check_tolerance("INST", "HEALTH_STATUS", "ARY", 3, [1, 0.1, 2], 0.01) expect(result).to be_a Float expect(stdout.string).to include("CHECK: INST HEALTH_STATUS ARY[0] was within range 2 to 4 with value == 2") expect(stdout.string).to include("CHECK: INST HEALTH_STATUS ARY[1] was within range 2.9 to 3.1 with value == 3") @@ -607,17 +734,17 @@ def openc3_script_sleep(_sleep_time = nil) describe "wait_check_expression" do it "waits and checks that an expression is true" do capture_io do |stdout| - result = wait_check_expression("true == true", 5) + result = wait_check_expression("true == true", 0.01) expect(result).to be_a Float expect(stdout.string).to match(/CHECK: true == true is TRUE/) end - expect { wait_check_expression("true == false", 0.1) }.to raise_error(/CHECK: true == false is FALSE/) + expect { wait_check_expression("true == false", 0.01) }.to raise_error(/CHECK: true == false is FALSE/) end it "logs instead of raises when disconnected" do $disconnect = true capture_io do |stdout| - result = wait_check_expression("true == false", 0.1) + result = wait_check_expression("true == false", 0.01) expect(result).to be_a Float expect(stdout.string).to match(/CHECK: true == false is FALSE/) end @@ -626,12 +753,12 @@ def openc3_script_sleep(_sleep_time = nil) it "waits and checks a logical expression" do capture_io do |stdout| - result = wait_check_expression("'STRING' == 'STRING'", 5) + result = wait_check_expression("'STRING' == 'STRING'", 0.01) expect(result).to be_a Float expect(stdout.string).to match(/CHECK: 'STRING' == 'STRING' is TRUE/) end - expect { wait_check_expression("1 == 2", 0.1) }.to raise_error(/CHECK: 1 == 2 is FALSE/) - expect { wait_check_expression("'STRING' == STRING", 0.1) }.to raise_error(NameError, "Uninitialized constant STRING. Did you mean 'STRING' as a string?") + expect { wait_check_expression("1 == 2", 0.01) }.to raise_error(/CHECK: 1 == 2 is FALSE/) + expect { wait_check_expression("'STRING' == STRING", 0.01) }.to raise_error(NameError, "Uninitialized constant STRING. Did you mean 'STRING' as a string?") end end @@ -646,7 +773,7 @@ def openc3_script_sleep(_sleep_time = nil) it "prints warning if packet not received" do @count = false capture_io do |stdout| - result = wait_packet("INST", "HEALTH_STATUS", 1, 0.5) + result = wait_packet("INST", "HEALTH_STATUS", 1, 0.1) expect(result).to be false expect(stdout.string).to match(/WAIT: INST HEALTH_STATUS expected to be received 1 times but only received 0 times/) end @@ -655,7 +782,7 @@ def openc3_script_sleep(_sleep_time = nil) it "prints success if the packet is received" do @count = true capture_io do |stdout| - result = wait_packet("INST", "HEALTH_STATUS", 5, 0.5) + result = wait_packet("INST", "HEALTH_STATUS", 5, 0.1) if cancel expect(result).to be false expect(stdout.string).to match(/WAIT: INST HEALTH_STATUS expected to be received 5 times/) @@ -674,14 +801,14 @@ def openc3_script_sleep(_sleep_time = nil) it "raises a check error if packet not received" do @count = false - expect { wait_check_packet("INST", "HEALTH_STATUS", 1, 0.5) }.to raise_error(/CHECK: INST HEALTH_STATUS expected to be received 1 times but only received 0 times/) + expect { wait_check_packet("INST", "HEALTH_STATUS", 1, 0.1) }.to raise_error(/CHECK: INST HEALTH_STATUS expected to be received 1 times but only received 0 times/) end it "logs instead of raises if disconnected" do $disconnect = true @count = false capture_io do |stdout| - result = wait_check_packet("INST", "HEALTH_STATUS", 1, 0.5) + result = wait_check_packet("INST", "HEALTH_STATUS", 1, 0.1) expect(result).to be_a Float expect(stdout.string).to match(/CHECK: INST HEALTH_STATUS expected to be received 1 times but only received 0 times/) end @@ -692,9 +819,9 @@ def openc3_script_sleep(_sleep_time = nil) @count = true capture_io do |stdout| if cancel - expect { wait_check_packet("INST", "HEALTH_STATUS", 5, 0.5) }.to raise_error(/CHECK: INST HEALTH_STATUS expected to be received 5 times/) + expect { wait_check_packet("INST", "HEALTH_STATUS", 5, 0.1) }.to raise_error(/CHECK: INST HEALTH_STATUS expected to be received 5 times/) else - result = wait_check_packet("INST", "HEALTH_STATUS", 5, 0.5) + result = wait_check_packet("INST", "HEALTH_STATUS", 5, 0.1) expect(result).to be_a Float expect(stdout.string).to match(/CHECK: INST HEALTH_STATUS received 5 times after waiting/) end diff --git a/openc3/spec/script/extract_spec.rb b/openc3/spec/script/extract_spec.rb index eaad86d939..38621b8ed3 100644 --- a/openc3/spec/script/extract_spec.rb +++ b/openc3/spec/script/extract_spec.rb @@ -186,7 +186,33 @@ module OpenC3 it "should handle spaces throughout correctly" do expect(extract_fields_from_check_text("TARGET PACKET ITEM == \"This is a test\"")).to eql(['TARGET', 'PACKET', 'ITEM', "== \"This is a test\""]) - expect(extract_fields_from_check_text("TARGET PACKET ITEM == 'This is a test '")).to eql(['TARGET', 'PACKET', 'ITEM', " == 'This is a test '"]) + expect(extract_fields_from_check_text("TARGET PACKET ITEM == 'This is a test '")).to eql(['TARGET', 'PACKET', 'ITEM', "== 'This is a test '"]) + end + end + + describe "extract_operator_and_operand_from_comparison" do + it "should parse string operands" do + expect(extract_operator_and_operand_from_comparison("== 'foo'")).to eql(["==", "foo"]) + end + + it "should parse number operands" do + expect(extract_operator_and_operand_from_comparison("== 1")).to eql(["==", 1]) + end + + it "should parse list operands" do + expect(extract_operator_and_operand_from_comparison("in [1, 2, 3]")).to eql(["in", [1, 2, 3]]) + end + + it "should parse nil operands" do + expect(extract_operator_and_operand_from_comparison("== nil")).to eql(["==", nil]) + end + + it "should complain about invalid operators" do + expect { extract_operator_and_operand_from_comparison("^ 'foo'") }.to raise_error(/ERROR: Invalid/) + end + + it "should complain about unparsable operands" do + expect { extract_operator_and_operand_from_comparison("== foo") }.to raise_error(/ERROR: Unable/) end end end