diff --git a/openc3/lib/openc3/script/api_shared.rb b/openc3/lib/openc3/script/api_shared.rb index 3e3a0d7064..178752782e 100644 --- a/openc3/lib/openc3/script/api_shared.rb +++ b/openc3/lib/openc3/script/api_shared.rb @@ -864,7 +864,18 @@ def _check_eval(target_name, packet_name, item_name, comparison_to_eval, value) # 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 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 +894,23 @@ 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, JSON::ParserError + # 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 + + 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..3d6b6fc19a 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 '=': #{comparison}" 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: #{comparison}" 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..3a7a1a5ea0 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,28 @@ 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, json.JSONDecodeError): + # 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 + + 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..d6c0b94691 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,28 @@ 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, json.JSONDecodeError): + # 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 + + 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..cbb0acff8a 100644 --- a/openc3/python/openc3/utilities/extract.py +++ b/openc3/python/openc3/utilities/extract.py @@ -14,14 +14,16 @@ # 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,58 @@ 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 + + # 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 '=': {comparison}") - # 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 - return target_name, packet_name, item_name, comparison_to_eval + +# Splits `check()` comparison expressions, e.g. "== 'foo bar'" becomes ["==", "foo bar"] +def extract_operator_and_operand_from_comparison(comparison): + valid_operators = ["==", "!=", ">=", "<=", ">", "<", "in"] + + operator, operand = comparison.split(None, 1) # Python: second split arg is max number of splits + + 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: {comparison}") + + # 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/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/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