From 5d0a6b12d2af42ad1c9b8f8336df21718a09ef41 Mon Sep 17 00:00:00 2001 From: Ryan Freckleton Date: Thu, 5 Mar 2026 21:40:22 -0700 Subject: [PATCH] Add ternary operator mutations --- src/mutmut/node_mutation.py | 18 ++++++++++++++++++ tests/test_mutation.py | 20 +++++++++++++++----- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/mutmut/node_mutation.py b/src/mutmut/node_mutation.py index bf4b1f1d..0df9b650 100644 --- a/src/mutmut/node_mutation.py +++ b/src/mutmut/node_mutation.py @@ -242,6 +242,23 @@ def operator_assignment( yield node.with_changes(value=mutated_value) +def _wrap_low_precedence_expression(expression: cst.BaseExpression) -> cst.BaseExpression: + """Wrap low-precedence expressions before appending boolean operators.""" + if expression.lpar or expression.rpar: + return expression + + if isinstance(expression, cst.BooleanOperation | cst.IfExp | cst.Lambda | cst.NamedExpr): + return expression.with_changes(lpar=[cst.LeftParen()], rpar=[cst.RightParen()]) + + return expression + + +def operator_if_exp(node: cst.IfExp) -> Iterable[cst.IfExp]: + test = _wrap_low_precedence_expression(node.test) + yield node.with_changes(test=cst.BooleanOperation(left=test, operator=cst.And(), right=cst.Name("False"))) + yield node.with_changes(test=cst.BooleanOperation(left=test, operator=cst.Or(), right=cst.Name("True"))) + + def operator_match(node: cst.Match) -> Iterable[cst.CSTNode]: """Drop the case statements in a match.""" if len(node.cases) > 1: @@ -263,6 +280,7 @@ def operator_match(node: cst.Match) -> Iterable[cst.CSTNode]: (cst.Call, operator_symmetric_string_methods_swap), (cst.Call, operator_unsymmetrical_string_methods_swap), (cst.Lambda, operator_lambda), + (cst.IfExp, operator_if_exp), (cst.CSTNode, operator_keywords), # type: ignore[type-abstract] (cst.CSTNode, operator_swap_op), # type: ignore[type-abstract] (cst.Match, operator_match), diff --git a/tests/test_mutation.py b/tests/test_mutation.py index 7c9dd4d9..783e4cb3 100644 --- a/tests/test_mutation.py +++ b/tests/test_mutation.py @@ -244,6 +244,7 @@ def mutated_module(source: str) -> str: ("foo is not foo", "foo is foo"), ("a or b", "a and b"), ("a and b", "a or b"), + ("a if b else c", ["a if b and False else c", "a if b or True else c"]), ("not a", "a"), ("a < b", ["a <= b"]), ("a <= b", ["a < b"]), @@ -325,6 +326,13 @@ def foo() -> int: assert not mutants +def test_ternary_mutation_preserves_boolean_precedence(): + mutants = mutants_for_source("a if b or c else d") + + assert "a if (b or c) and False else d" in mutants + assert "a if (b or c) or True else d" in mutants + + def test_do_not_mutate_specific_functions(): source = """ class A: @@ -401,13 +409,15 @@ def test_function_with_annotation(): mutated_code = mutated_module(source) print(mutated_code) - expected_defs = [ - "def x_capitalize__mutmut_1(s : str):\n return s[0].title() - s[1:] if s else s", - "def x_capitalize__mutmut_2(s : str):\n return s[1].title() + s[1:] if s else s", - "def x_capitalize__mutmut_3(s : str):\n return s[0].title() + s[2:] if s else s", + expected_snippets = [ + "return s[0].title() + s[1:] if s and False else s", + "return s[0].title() + s[1:] if s or True else s", + "return s[0].title() - s[1:] if s else s", + "return s[1].title() + s[1:] if s else s", + "return s[0].title() + s[2:] if s else s", ] - for expected in expected_defs: + for expected in expected_snippets: print(expected) assert expected in mutated_code