"""Tests for pytest_leela.operators — mutation operator registry."""

import pytest

from pytest_leela.models import MutationPoint
from pytest_leela.operators import (
    ALL_OPERATORS,
    DEFAULT_OPERATORS,
    OPERATOR_CATEGORIES,
    TYPED_MUTATIONS,
    UNTYPED_MUTATIONS,
    build_allowed_keys,
    count_pruned,
    mutations_for,
)


def _make_point(
    node_type: str = "BinOp",
    original_op: str = "Add",
    inferred_type: str | None = None,
) -> MutationPoint:
    return MutationPoint(
        file_path="test.py",
        module_name="test",
        lineno=1,
        col_offset=0,
        node_type=node_type,
        original_op=original_op,
        inferred_type=inferred_type,
    )


def describe_mutations_for():
    def it_returns_typed_mutations_for_int_binop():
        point = _make_point(node_type="BinOp", original_op="Add", inferred_type="int")
        muts = mutations_for(point, use_types=True)
        assert "Sub" in muts
        assert "Mult" in muts
        assert "FloorDiv" in muts

    def it_prunes_str_addition():
        point = _make_point(node_type="BinOp", original_op="Add", inferred_type="str")
        muts = mutations_for(point, use_types=True)
        assert muts == []

    def it_returns_untyped_mutations_when_no_type():
        point = _make_point(node_type="BinOp", original_op="Add", inferred_type=None)
        muts = mutations_for(point, use_types=True)
        assert "Sub" in muts
        assert "Mult" in muts
        # Untyped Add doesn't include FloorDiv
        assert "FloorDiv" not in muts

    def it_mutates_comparisons():
        point = _make_point(node_type="Compare", original_op="Lt", inferred_type="int")
        muts = mutations_for(point, use_types=True)
        assert "LtE" in muts
        assert "GtE" in muts

    def it_mutates_boolop():
        point = _make_point(node_type="BoolOp", original_op="And", inferred_type="bool")
        muts = mutations_for(point, use_types=True)
        assert muts == ["Or"]

    def it_mutates_return_true():
        point = _make_point(
            node_type="Return", original_op="True", inferred_type="bool"
        )
        muts = mutations_for(point, use_types=True)
        assert "False" in muts

    def it_skips_negate_for_zero_int_literal():
        """return 0 → return -0 is equivalent; no mutation should be generated."""
        point = _make_point(node_type="Return", original_op="zero_int_literal")
        assert mutations_for(point, use_types=False) == []

    def it_skips_negate_for_zero_int_literal_typed():
        """Typed variant: return 0 should also produce no mutations."""
        point = _make_point(
            node_type="Return", original_op="zero_int_literal", inferred_type="int"
        )
        assert mutations_for(point, use_types=True) == []

    def it_skips_negate_for_zero_float_literal():
        """return 0.0 → return -0.0 is equivalent; no mutation should be generated."""
        point = _make_point(node_type="Return", original_op="zero_float_literal")
        assert mutations_for(point, use_types=False) == []

    def it_skips_negate_for_zero_float_literal_typed():
        """Typed variant: return 0.0 should also produce no mutations."""
        point = _make_point(
            node_type="Return", original_op="zero_float_literal", inferred_type="float"
        )
        assert mutations_for(point, use_types=True) == []

    def it_still_negates_nonzero_int_literal():
        """Non-zero int literals should still get the negate mutation."""
        point = _make_point(node_type="Return", original_op="int_literal")
        assert mutations_for(point, use_types=False) == ["negate"]

    def it_still_negates_nonzero_float_literal():
        """Non-zero float literals should still get the negate mutation."""
        point = _make_point(node_type="Return", original_op="float_literal")
        assert mutations_for(point, use_types=False) == ["negate"]

    def describe_bitwise_operators():
        def it_mutates_bitand_untyped():
            point = _make_point(node_type="BinOp", original_op="BitAnd")
            muts = mutations_for(point, use_types=False)
            assert "BitOr" in muts
            assert "BitXor" in muts

        def it_mutates_bitor_untyped():
            point = _make_point(node_type="BinOp", original_op="BitOr")
            muts = mutations_for(point, use_types=False)
            assert "BitAnd" in muts
            assert "BitXor" in muts

        def it_mutates_bitxor_untyped():
            point = _make_point(node_type="BinOp", original_op="BitXor")
            muts = mutations_for(point, use_types=False)
            assert "BitAnd" in muts
            assert "BitOr" in muts

        def it_mutates_lshift_untyped():
            point = _make_point(node_type="BinOp", original_op="LShift")
            muts = mutations_for(point, use_types=False)
            assert muts == ["RShift"]

        def it_mutates_rshift_untyped():
            point = _make_point(node_type="BinOp", original_op="RShift")
            muts = mutations_for(point, use_types=False)
            assert muts == ["LShift"]

        def it_keeps_all_mutations_for_int_bitand():
            point = _make_point(
                node_type="BinOp", original_op="BitAnd", inferred_type="int"
            )
            muts = mutations_for(point, use_types=True)
            assert "BitOr" in muts
            assert "BitXor" in muts

        def it_keeps_all_mutations_for_int_bitor():
            point = _make_point(
                node_type="BinOp", original_op="BitOr", inferred_type="int"
            )
            muts = mutations_for(point, use_types=True)
            assert "BitAnd" in muts
            assert "BitXor" in muts

        def it_keeps_all_mutations_for_int_bitxor():
            point = _make_point(
                node_type="BinOp", original_op="BitXor", inferred_type="int"
            )
            muts = mutations_for(point, use_types=True)
            assert "BitAnd" in muts
            assert "BitOr" in muts

        def it_keeps_shift_mutations_for_int():
            point_l = _make_point(
                node_type="BinOp", original_op="LShift", inferred_type="int"
            )
            point_r = _make_point(
                node_type="BinOp", original_op="RShift", inferred_type="int"
            )
            assert mutations_for(point_l, use_types=True) == ["RShift"]
            assert mutations_for(point_r, use_types=True) == ["LShift"]

        def it_prunes_bool_bitand_to_single_mutation():
            point = _make_point(
                node_type="BinOp", original_op="BitAnd", inferred_type="bool"
            )
            muts = mutations_for(point, use_types=True)
            assert muts == ["BitOr"]

        def it_prunes_bool_bitor_to_single_mutation():
            point = _make_point(
                node_type="BinOp", original_op="BitOr", inferred_type="bool"
            )
            muts = mutations_for(point, use_types=True)
            assert muts == ["BitAnd"]

        def it_keeps_both_mutations_for_bool_bitxor():
            point = _make_point(
                node_type="BinOp", original_op="BitXor", inferred_type="bool"
            )
            muts = mutations_for(point, use_types=True)
            assert muts == ["BitAnd", "BitOr"]

        def it_falls_through_to_untyped_for_bool_shifts():
            point_l = _make_point(
                node_type="BinOp", original_op="LShift", inferred_type="bool"
            )
            point_r = _make_point(
                node_type="BinOp", original_op="RShift", inferred_type="bool"
            )
            assert mutations_for(point_l, use_types=True) == ["RShift"]
            assert mutations_for(point_r, use_types=True) == ["LShift"]

    def describe_augmented_assignment():
        def it_mutates_augassign_add_untyped():
            point = _make_point(node_type="AugAssign", original_op="Add")
            muts = mutations_for(point, use_types=False)
            assert "Sub" in muts
            assert "Mult" in muts

        def it_mutates_augassign_sub_untyped():
            point = _make_point(node_type="AugAssign", original_op="Sub")
            muts = mutations_for(point, use_types=False)
            assert "Add" in muts
            assert "Mult" in muts

        def it_mutates_augassign_mult_untyped():
            point = _make_point(node_type="AugAssign", original_op="Mult")
            muts = mutations_for(point, use_types=False)
            assert "Add" in muts
            assert "FloorDiv" in muts

        def it_mutates_augassign_pow_untyped():
            point = _make_point(node_type="AugAssign", original_op="Pow")
            muts = mutations_for(point, use_types=False)
            assert muts == ["Mult"]

        def it_mutates_augassign_bitand_untyped():
            point = _make_point(node_type="AugAssign", original_op="BitAnd")
            muts = mutations_for(point, use_types=False)
            assert "BitOr" in muts
            assert "BitXor" in muts

        def it_mutates_augassign_lshift_untyped():
            point = _make_point(node_type="AugAssign", original_op="LShift")
            muts = mutations_for(point, use_types=False)
            assert muts == ["RShift"]

        def it_expands_augassign_add_for_int():
            point = _make_point(
                node_type="AugAssign", original_op="Add", inferred_type="int"
            )
            muts = mutations_for(point, use_types=True)
            assert "Sub" in muts
            assert "Mult" in muts
            assert "FloorDiv" in muts

        def it_prunes_augassign_add_for_str():
            point = _make_point(
                node_type="AugAssign", original_op="Add", inferred_type="str"
            )
            muts = mutations_for(point, use_types=True)
            assert muts == []

        def it_expands_augassign_add_for_float():
            point = _make_point(
                node_type="AugAssign", original_op="Add", inferred_type="float"
            )
            muts = mutations_for(point, use_types=True)
            assert "Sub" in muts
            assert "Div" in muts

        def it_prunes_augassign_bitand_for_bool():
            point = _make_point(
                node_type="AugAssign", original_op="BitAnd", inferred_type="bool"
            )
            muts = mutations_for(point, use_types=True)
            assert muts == ["BitOr"]

        def it_keeps_augassign_bitxor_mutations_for_bool():
            point = _make_point(
                node_type="AugAssign", original_op="BitXor", inferred_type="bool"
            )
            muts = mutations_for(point, use_types=True)
            assert muts == ["BitAnd", "BitOr"]

        def it_falls_through_for_augassign_unknown_type():
            point = _make_point(
                node_type="AugAssign", original_op="Add", inferred_type="complex"
            )
            muts = mutations_for(point, use_types=True)
            assert "Sub" in muts
            assert "Mult" in muts

    def describe_ifexp():
        def it_returns_all_three_mutations():
            point = _make_point(node_type="IfExp", original_op="ternary")
            muts = mutations_for(point, use_types=False)
            assert muts == ["swap_branches", "always_true", "always_false"]

        def it_returns_same_mutations_with_types_enabled():
            point = _make_point(node_type="IfExp", original_op="ternary")
            muts = mutations_for(point, use_types=True)
            assert muts == ["swap_branches", "always_true", "always_false"]

    def describe_except_handler():
        def it_returns_broaden_and_body_to_raise_for_typed():
            point = _make_point(node_type="ExceptHandler", original_op="typed")
            muts = mutations_for(point, use_types=False)
            assert muts == ["broaden", "body_to_raise"]

        def it_returns_only_body_to_raise_for_bare():
            point = _make_point(node_type="ExceptHandler", original_op="bare")
            muts = mutations_for(point, use_types=False)
            assert muts == ["body_to_raise"]

        def it_returns_only_body_to_raise_for_typed_broadest():
            point = _make_point(node_type="ExceptHandler", original_op="typed_broadest")
            muts = mutations_for(point, use_types=False)
            assert muts == ["body_to_raise"]

        def it_returns_only_broaden_for_typed_raise_body():
            point = _make_point(
                node_type="ExceptHandler", original_op="typed_raise_body"
            )
            muts = mutations_for(point, use_types=False)
            assert muts == ["broaden"]

        def it_falls_through_to_untyped_with_types_enabled():
            """No typed rules for ExceptHandler, so falls through to untyped."""
            point = _make_point(node_type="ExceptHandler", original_op="typed")
            muts = mutations_for(point, use_types=True)
            assert muts == ["broaden", "body_to_raise"]

    def describe_break_continue():
        def it_mutates_break_to_continue():
            point = _make_point(node_type="Break", original_op="break")
            muts = mutations_for(point, use_types=False)
            assert muts == ["continue"]

        def it_mutates_continue_to_break():
            point = _make_point(node_type="Continue", original_op="continue")
            muts = mutations_for(point, use_types=False)
            assert muts == ["break"]

        def it_returns_same_mutations_with_types_enabled():
            point_b = _make_point(node_type="Break", original_op="break")
            point_c = _make_point(node_type="Continue", original_op="continue")
            assert mutations_for(point_b, use_types=True) == ["continue"]
            assert mutations_for(point_c, use_types=True) == ["break"]

    def it_falls_through_to_untyped_for_unknown_typed_key():
        # A type that doesn't have a typed rule should fall through to untyped
        point = _make_point(
            node_type="BinOp", original_op="Add", inferred_type="complex"
        )
        muts = mutations_for(point, use_types=True)
        # Falls through to untyped: ["Sub", "Mult"]
        assert "Sub" in muts
        assert "Mult" in muts

    def it_uses_untyped_when_types_disabled_even_with_inferred_type():
        """Kills and→or on `if use_types and point.inferred_type:`."""
        point = _make_point(node_type="BinOp", original_op="Add", inferred_type="int")
        # With use_types=False, should return untyped [Sub, Mult] (2 items)
        # not typed int [Sub, Mult, FloorDiv] (3 items)
        muts = mutations_for(point, use_types=False)
        assert muts == ["Sub", "Mult"]

    def it_returns_empty_list_for_unknown_node_type():
        """Kills return expr→return None on UNTYPED_MUTATIONS.get fallback."""
        point = _make_point(node_type="UnknownNode", original_op="unknown_op")
        muts = mutations_for(point, use_types=False)
        assert muts == []
        assert isinstance(muts, list)


def describe_count_pruned():
    def it_counts_pruned_mutations():
        # str + str: typed gives 0 mutations, untyped gives 2 (Sub, Mult)
        point = _make_point(node_type="BinOp", original_op="Add", inferred_type="str")
        pruned = count_pruned([point], use_types=True)
        assert pruned == 2  # untyped has ["Sub", "Mult"], typed has []

    def it_returns_zero_when_types_disabled():
        point = _make_point(node_type="BinOp", original_op="Add", inferred_type="str")
        pruned = count_pruned([point], use_types=False)
        assert pruned == 0

    def it_counts_zero_for_non_pruned():
        # int Add: typed has 3 mutations, untyped has 2 -> pruned = 2 - 3 = -1? No.
        # Actually untyped Add = ["Sub", "Mult"] (2), typed int Add = ["Sub", "Mult", "FloorDiv"] (3)
        # pruned = 2 - 3 = -1, but that's expansion not pruning
        point = _make_point(node_type="BinOp", original_op="Add", inferred_type="int")
        pruned = count_pruned([point], use_types=True)
        # This can be negative (expansion), which is expected behavior
        assert pruned == -1

    def it_handles_empty_list():
        pruned = count_pruned([], use_types=True)
        assert pruned == 0

    def it_returns_non_negative_for_pruned_types():
        """When typed has fewer mutations than untyped, result is positive."""
        point = _make_point(node_type="BinOp", original_op="Add", inferred_type="str")
        pruned = count_pruned([point], use_types=True)
        assert pruned > 0

    def it_sums_across_multiple_points():
        """count_pruned sums pruned counts across all points."""
        points = [
            _make_point(node_type="BinOp", original_op="Add", inferred_type="str"),
            _make_point(node_type="BinOp", original_op="Mult", inferred_type="str"),
        ]
        pruned = count_pruned(points, use_types=True)
        # str Add: untyped 2, typed 0 -> pruned 2
        # str Mult: untyped 2, typed 0 -> pruned 2
        assert pruned == 4

    def describe_with_allowed_keys():
        def it_passes_allowed_keys_through_to_mutations_for():
            """count_pruned respects allowed_keys filtering."""
            point = _make_point(
                node_type="BinOp", original_op="Add", inferred_type="str"
            )
            keys = build_allowed_keys(("arithmetic",))
            # With arithmetic enabled, str Add still gets pruned (typed=[], untyped=2)
            pruned = count_pruned([point], use_types=True, allowed_keys=keys)
            assert pruned == 2

        def it_returns_zero_for_filtered_out_category():
            """Points not in allowed_keys produce 0 pruned (both typed and untyped are 0)."""
            point = _make_point(
                node_type="BinOp", original_op="Add", inferred_type="str"
            )
            keys = build_allowed_keys(("comparison",))
            pruned = count_pruned([point], use_types=True, allowed_keys=keys)
            assert pruned == 0


def describe_operator_categories():
    def it_covers_every_untyped_mutation_key():
        """Every key in UNTYPED_MUTATIONS must appear in exactly one category."""
        all_categorised: set[tuple[str, str]] = set()
        for keys in OPERATOR_CATEGORIES.values():
            all_categorised |= keys
        for key in UNTYPED_MUTATIONS:
            assert key in all_categorised, f"UNTYPED key {key} not in any category"

    def it_covers_every_typed_mutation_key():
        """Every (node_type, op) prefix of TYPED_MUTATIONS must appear in a category."""
        all_categorised: set[tuple[str, str]] = set()
        for keys in OPERATOR_CATEGORIES.values():
            all_categorised |= keys
        for key in TYPED_MUTATIONS:
            prefix = (key[0], key[1])
            assert prefix in all_categorised, (
                f"TYPED key prefix {prefix} (from {key}) not in any category"
            )

    def it_assigns_each_key_to_exactly_one_category():
        """No key appears in more than one category."""
        seen: dict[tuple[str, str], str] = {}
        for cat_name, keys in OPERATOR_CATEGORIES.items():
            for key in keys:
                assert key not in seen, (
                    f"Key {key} appears in both '{seen[key]}' and '{cat_name}'"
                )
                seen[key] = cat_name

    def it_has_no_empty_categories():
        """Every category must contain at least one key."""
        for cat_name, keys in OPERATOR_CATEGORIES.items():
            assert len(keys) > 0, f"Category '{cat_name}' is empty"

    def it_has_correct_default_operators():
        """DEFAULT_OPERATORS contains the expected v0.1.0 categories."""
        assert DEFAULT_OPERATORS == (
            "arithmetic",
            "comparison",
            "boolean",
            "unary",
            "return",
        )

    def it_has_all_operators_matching_category_keys():
        """ALL_OPERATORS contains every category name."""
        assert set(ALL_OPERATORS) == set(OPERATOR_CATEGORIES.keys())

    def it_default_operators_are_subset_of_all():
        """Every default operator is a valid category."""
        for op in DEFAULT_OPERATORS:
            assert op in ALL_OPERATORS, f"Default operator '{op}' not in ALL_OPERATORS"


def describe_build_allowed_keys():
    def it_returns_none_for_none_categories():
        """None means no filtering — all operators enabled."""
        assert build_allowed_keys(None) is None

    def it_returns_frozenset_for_valid_categories():
        keys = build_allowed_keys(("arithmetic",))
        assert isinstance(keys, frozenset)
        assert ("BinOp", "Add") in keys

    def it_raises_for_unknown_category():
        """Unknown category names raise ValueError."""
        with pytest.raises(ValueError, match="Unknown operator categories"):
            build_allowed_keys(("nonexistent",))

    def it_raises_for_mixed_valid_and_unknown():
        """Even if some categories are valid, unknown ones raise ValueError."""
        with pytest.raises(ValueError, match="nonexistent"):
            build_allowed_keys(("arithmetic", "nonexistent"))

    def it_returns_empty_frozenset_for_empty_list():
        keys = build_allowed_keys(())
        assert keys == frozenset()

    def it_unions_multiple_categories():
        keys = build_allowed_keys(("arithmetic", "comparison"))
        assert ("BinOp", "Add") in keys
        assert ("Compare", "Lt") in keys


def describe_mutations_for_with_allowed_keys():
    def it_returns_mutations_when_category_is_enabled():
        """An arithmetic operator returns mutations when 'arithmetic' keys are passed."""
        point = _make_point(node_type="BinOp", original_op="Add")
        keys = build_allowed_keys(("arithmetic",))
        muts = mutations_for(point, use_types=False, allowed_keys=keys)
        assert muts == ["Sub", "Mult"]

    def it_returns_empty_when_category_is_not_enabled():
        """An arithmetic operator returns [] when only 'comparison' keys are passed."""
        point = _make_point(node_type="BinOp", original_op="Add")
        keys = build_allowed_keys(("comparison",))
        muts = mutations_for(point, use_types=False, allowed_keys=keys)
        assert muts == []

    def it_returns_all_when_allowed_keys_is_none():
        """None means no filtering — backwards compatible."""
        point = _make_point(node_type="BinOp", original_op="Add")
        muts = mutations_for(point, use_types=False, allowed_keys=None)
        assert muts == ["Sub", "Mult"]

    def it_filters_comparison_operators():
        """Comparison operators return mutations when 'comparison' keys are passed."""
        point = _make_point(node_type="Compare", original_op="Lt")
        keys = build_allowed_keys(("comparison",))
        muts = mutations_for(point, use_types=False, allowed_keys=keys)
        assert "LtE" in muts
        assert "GtE" in muts

    def it_filters_boolean_operators():
        """Boolean operators work when 'boolean' keys are passed."""
        point = _make_point(node_type="BoolOp", original_op="And")
        keys = build_allowed_keys(("boolean",))
        muts = mutations_for(point, use_types=False, allowed_keys=keys)
        assert muts == ["Or"]

    def it_filters_unary_operators():
        """Unary operators work when 'unary' keys are passed."""
        point = _make_point(node_type="UnaryOp", original_op="Not")
        keys = build_allowed_keys(("unary",))
        muts = mutations_for(point, use_types=False, allowed_keys=keys)
        assert muts == ["_remove"]

    def it_filters_return_operators():
        """Return mutations work when 'return' keys are passed."""
        point = _make_point(node_type="Return", original_op="True")
        keys = build_allowed_keys(("return",))
        muts = mutations_for(point, use_types=False, allowed_keys=keys)
        assert muts == ["False"]

    def it_filters_bitwise_operators():
        """Bitwise operators work when 'bitwise' keys are passed."""
        point = _make_point(node_type="BinOp", original_op="BitAnd")
        keys = build_allowed_keys(("bitwise",))
        muts = mutations_for(point, use_types=False, allowed_keys=keys)
        assert "BitOr" in muts
        assert "BitXor" in muts

    def it_filters_augmented_assign():
        """AugAssign operators work when 'augmented_assign' keys are passed."""
        point = _make_point(node_type="AugAssign", original_op="Add")
        keys = build_allowed_keys(("augmented_assign",))
        muts = mutations_for(point, use_types=False, allowed_keys=keys)
        assert "Sub" in muts
        assert "Mult" in muts

    def it_filters_ternary():
        """IfExp operators work when 'ternary' keys are passed."""
        point = _make_point(node_type="IfExp", original_op="ternary")
        keys = build_allowed_keys(("ternary",))
        muts = mutations_for(point, use_types=False, allowed_keys=keys)
        assert muts == ["swap_branches", "always_true", "always_false"]

    def it_filters_control_flow():
        """Break/Continue operators work when 'control_flow' keys are passed."""
        point = _make_point(node_type="Break", original_op="break")
        keys = build_allowed_keys(("control_flow",))
        muts = mutations_for(point, use_types=False, allowed_keys=keys)
        assert muts == ["continue"]

    def it_filters_exception():
        """ExceptHandler operators work when 'exception' keys are passed."""
        point = _make_point(node_type="ExceptHandler", original_op="typed")
        keys = build_allowed_keys(("exception",))
        muts = mutations_for(point, use_types=False, allowed_keys=keys)
        assert muts == ["broaden", "body_to_raise"]

    def it_supports_multiple_categories():
        """Multiple categories can be combined."""
        point_arith = _make_point(node_type="BinOp", original_op="Add")
        point_cmp = _make_point(node_type="Compare", original_op="Lt")
        keys = build_allowed_keys(("arithmetic", "comparison"))
        assert mutations_for(point_arith, use_types=False, allowed_keys=keys) == [
            "Sub",
            "Mult",
        ]
        assert "LtE" in mutations_for(point_cmp, use_types=False, allowed_keys=keys)

    def it_respects_type_awareness_within_category():
        """Category filtering and type awareness work together."""
        point = _make_point(node_type="BinOp", original_op="Add", inferred_type="int")
        keys = build_allowed_keys(("arithmetic",))
        muts = mutations_for(point, use_types=True, allowed_keys=keys)
        assert "FloorDiv" in muts  # typed int Add includes FloorDiv

    def it_prunes_typed_within_category():
        """Category filtering + type pruning: str Add typed to [] within arithmetic."""
        point = _make_point(node_type="BinOp", original_op="Add", inferred_type="str")
        keys = build_allowed_keys(("arithmetic",))
        muts = mutations_for(point, use_types=True, allowed_keys=keys)
        assert muts == []

    def describe_default_categories():
        def it_includes_arithmetic():
            point = _make_point(node_type="BinOp", original_op="Add")
            keys = build_allowed_keys(DEFAULT_OPERATORS)
            muts = mutations_for(point, use_types=False, allowed_keys=keys)
            assert muts == ["Sub", "Mult"]

        def it_includes_comparison():
            point = _make_point(node_type="Compare", original_op="Eq")
            keys = build_allowed_keys(DEFAULT_OPERATORS)
            muts = mutations_for(point, use_types=False, allowed_keys=keys)
            assert muts == ["NotEq"]

        def it_includes_boolean():
            point = _make_point(node_type="BoolOp", original_op="And")
            keys = build_allowed_keys(DEFAULT_OPERATORS)
            muts = mutations_for(point, use_types=False, allowed_keys=keys)
            assert muts == ["Or"]

        def it_includes_unary():
            point = _make_point(node_type="UnaryOp", original_op="USub")
            keys = build_allowed_keys(DEFAULT_OPERATORS)
            muts = mutations_for(point, use_types=False, allowed_keys=keys)
            assert muts == ["UAdd"]

        def it_includes_return():
            point = _make_point(node_type="Return", original_op="True")
            keys = build_allowed_keys(DEFAULT_OPERATORS)
            muts = mutations_for(point, use_types=False, allowed_keys=keys)
            assert muts == ["False"]

        def it_excludes_bitwise():
            point = _make_point(node_type="BinOp", original_op="BitAnd")
            keys = build_allowed_keys(DEFAULT_OPERATORS)
            muts = mutations_for(point, use_types=False, allowed_keys=keys)
            assert muts == []

        def it_excludes_augmented_assign():
            point = _make_point(node_type="AugAssign", original_op="Add")
            keys = build_allowed_keys(DEFAULT_OPERATORS)
            muts = mutations_for(point, use_types=False, allowed_keys=keys)
            assert muts == []

        def it_excludes_ternary():
            point = _make_point(node_type="IfExp", original_op="ternary")
            keys = build_allowed_keys(DEFAULT_OPERATORS)
            muts = mutations_for(point, use_types=False, allowed_keys=keys)
            assert muts == []

        def it_excludes_control_flow():
            point = _make_point(node_type="Break", original_op="break")
            keys = build_allowed_keys(DEFAULT_OPERATORS)
            muts = mutations_for(point, use_types=False, allowed_keys=keys)
            assert muts == []

        def it_excludes_exception():
            point = _make_point(node_type="ExceptHandler", original_op="typed")
            keys = build_allowed_keys(DEFAULT_OPERATORS)
            muts = mutations_for(point, use_types=False, allowed_keys=keys)
            assert muts == []

    def it_allows_empty_frozenset():
        """Empty frozenset means nothing is enabled — all mutations are filtered out."""
        point = _make_point(node_type="BinOp", original_op="Add")
        keys = build_allowed_keys(())
        muts = mutations_for(point, use_types=False, allowed_keys=keys)
        assert muts == []
