diff --git a/app/feedback/symbolic.py b/app/feedback/symbolic.py index d341fa8..366ac07 100644 --- a/app/feedback/symbolic.py +++ b/app/feedback/symbolic.py @@ -14,6 +14,7 @@ feedback_generators["EQUIVALENCES"] = criteria_equivalences feedback_generators["INTERNAL"] = lambda tag: lambda inputs: { "ABSOLUTE_VALUE_NOTATION_AMBIGUITY": f"Notation in {inputs.get('name','')} might be ambiguous, use `Abs(.)` instead of `|.|`", + "BRACKET_NOTATION_MISMATCH": f"Brackets in `{inputs.get('x','')}` do not match. Every `(`, `[` or `{{` must be closed with the corresponding `)`, `]` or `}}` — different bracket types cannot be mixed to close the same group.", "NO_RESPONSE": "No response submitted.", "MULTIPLE_ANSWER_FAIL_ALL": "At least one answer or response was incorrect.", "MULTIPLE_ANSWER_FAIL_RESPONSE": "At least one response was incorrect.", diff --git a/app/tests/expression_utilities_test.py b/app/tests/expression_utilities_test.py index 4476820..c743291 100644 --- a/app/tests/expression_utilities_test.py +++ b/app/tests/expression_utilities_test.py @@ -4,10 +4,12 @@ from ..utility.expression_utilities import ( compute_relative_tolerance_from_significant_decimals, convert_absolute_notation, + convert_bracket_notation, convert_unicode_dashes, create_expression_set, extract_latex, find_matching_parenthesis, + has_matching_brackets, is_multiple_answers_wrapper, latex_symbols, preprocess_expression, @@ -183,6 +185,66 @@ def test_find_matching_parenthesis(self, string, index, delimiters, expected): assert result == expected +class TestHasMatchingBrackets: + + @pytest.mark.parametrize( + "expr, expected", [ + ("x+y", True), + ("(x+y)", True), + ("[x+y]", True), + ("{x+y}", True), + ("[x+(y-1)]", True), + ("{(x+1), (x-1)}", True), + # Mismatched bracket types + ("[x+y)", False), + ("(x+y]", False), + ("{x+y)", False), + ("[(x+y]", False), + # Unbalanced brackets + ("[x+y", False), + ("x+y]", False), + ("((x+y)", False), + ] + ) + def test_has_matching_brackets(self, expr, expected): + assert has_matching_brackets(expr) is expected + + +class TestConvertBracketNotation: + + @pytest.mark.parametrize( + "expr, expected", [ + ("[x+y]", "(x+y)"), + ("[x+(y-1)]", "(x+(y-1))"), + ("{x+1}*{x-2}", "(x+1)*(x-2)"), + ] + ) + def test_matched_brackets_are_converted(self, expr, expected): + result, feedback = convert_bracket_notation(expr) + assert result == expected + assert feedback is None + + def test_multiple_answers_wrapper_left_untouched(self): + result, feedback = convert_bracket_notation("{x+1, x-1}") + assert result == "{x+1, x-1}" + assert feedback is None + + @pytest.mark.parametrize( + "expr", [ + "[x+y)", + "(x+y]", + "{x+y)", + "[x+y", + "x+y]", + ] + ) + def test_mismatched_brackets_are_rejected(self, expr): + result, feedback = convert_bracket_notation(expr) + assert result == expr + assert feedback is not None + assert feedback[0] == "BRACKET_NOTATION_MISMATCH" + + class TestSubstitute: @pytest.mark.parametrize( @@ -396,4 +458,26 @@ def test_ambiguous_pipes_returns_failure(self): success, expr, feedback = preprocess_expression("response", "|x|y|z|", {}) assert success is False assert feedback is not None - assert feedback[0] == "ABSOLUTE_VALUE_NOTATION_AMBIGUITY" \ No newline at end of file + assert feedback[0] == "ABSOLUTE_VALUE_NOTATION_AMBIGUITY" + + def test_square_brackets_converted(self): + success, expr, feedback = preprocess_expression("response", "[x+y]", {}) + assert success is True + assert expr == "(x+y)" + assert feedback is None + + @pytest.mark.parametrize( + "expr", [ + "[x+y)", + "(x+y]", + "{x+y)", + "[x+y", + "x+y]", + ] + ) + def test_mismatched_brackets_returns_failure(self, expr): + success, result, feedback = preprocess_expression("response", expr, {}) + assert success is False + assert result == expr + assert feedback is not None + assert feedback[0] == "BRACKET_NOTATION_MISMATCH" \ No newline at end of file diff --git a/app/utility/expression_utilities.py b/app/utility/expression_utilities.py index b915bc5..67e9889 100644 --- a/app/utility/expression_utilities.py +++ b/app/utility/expression_utilities.py @@ -176,11 +176,20 @@ def convert_bracket_notation(expr): sets, and tuples respectively. {} is left untouched when it spans the entire expression, since that denotes a set of multiple acceptable answers (see create_expression_set). + + Brackets must be closed with the matching type, e.g. "[x+y)" is + rejected rather than silently reinterpreted as "(x+y)", since this + project does not support interval/limit notation. If the brackets in + expr are mismatched, expr is returned unconverted together with + feedback describing the problem. """ + if not has_matching_brackets(expr): + tag = "BRACKET_NOTATION_MISMATCH" + return expr, (tag, feedback_string_generators["INTERNAL"](tag)({'x': expr})) expr = expr.replace("[", "(").replace("]", ")") if is_multiple_answers_wrapper(expr): - return expr - return expr.replace("{", "(").replace("}", ")") + return expr, None + return expr.replace("{", "(").replace("}", ")"), None def convert_absolute_notation(expr, name): @@ -467,6 +476,23 @@ def substitute_input_symbols(exprs, params): return exprs +def has_matching_brackets(expr): + """ + True if every occurrence of (, [, { in expr is closed by the closing + bracket of the same type, correctly nested. Mismatched types (e.g. the + "[x+y)" in interval/limit notation) or unbalanced brackets are rejected. + """ + closing_to_opening = {')': '(', ']': '[', '}': '{'} + stack = [] + for char in expr: + if char in '([{': + stack.append(char) + elif char in ')]}': + if not stack or stack.pop() != closing_to_opening[char]: + return False + return not stack + + def find_matching_parenthesis(string, index, delimiters=None): depth = 0 if delimiters is None: @@ -762,13 +788,13 @@ def substitutions_sort_key(x): def preprocess_expression(name, expr, parameters): expr = substitute_input_symbols(expr.strip(), parameters) expr = expr[0] + bracket_feedback = None if not parameters.get("strict_syntax", False): - expr = convert_bracket_notation(expr) + expr, bracket_feedback = convert_bracket_notation(expr) expr, abs_feedback = convert_absolute_notation(expr, name) - success = True - if abs_feedback is not None: - success = False - return success, expr, abs_feedback + feedback = bracket_feedback or abs_feedback + success = feedback is None + return success, expr, feedback def parse_expression(expr_string, parsing_params): ''' @@ -793,7 +819,7 @@ def parse_expression(expr_string, parsing_params): for expr in expr_set: if not strict_syntax: - expr = convert_bracket_notation(expr) + expr, _ = convert_bracket_notation(expr) expr = preprocess_according_to_chosen_convention(expr, parsing_params) substitutions = list(set(substitutions))