diff options
Diffstat (limited to 'tests/errors')
| -rw-r--r-- | tests/errors/context.test.py | 107 | ||||
| -rw-r--r-- | tests/errors/errors.test.py | 149 | ||||
| -rw-r--r-- | tests/errors/formatting.test.py | 127 | ||||
| -rw-r--r-- | tests/errors/quantifier.test.py | 12 |
4 files changed, 356 insertions, 39 deletions
diff --git a/tests/errors/context.test.py b/tests/errors/context.test.py new file mode 100644 index 0000000..fff9e09 --- /dev/null +++ b/tests/errors/context.test.py @@ -0,0 +1,107 @@ +"""Tests for the caller-source-location capture helpers in :mod:`edify.errors.context`.""" + +from unittest import mock + +from edify.errors.context import ( + CallerContext, + _context_for_frame, + capture_caller_context, +) + + +class _FakeCode: + """A stand-in for ``types.CodeType`` used to force specific ``co_positions`` behaviour.""" + + def __init__(self, filename: str, positions: list[tuple]) -> None: + self.co_filename = filename + self._positions = list(positions) + + def co_positions(self): + return iter(self._positions) + + +class _FakeFrame: + """A stand-in for ``types.FrameType`` shaped for :func:`_context_for_frame`.""" + + def __init__( + self, + filename: str, + f_lineno: int, + f_lasti: int, + positions: list[tuple], + ) -> None: + self.f_code = _FakeCode(filename, positions) + self.f_lineno = f_lineno + self.f_lasti = f_lasti + + +def test_capture_caller_context_returns_none_when_every_frame_is_inside_edify(): + with mock.patch("edify.errors.context.sys._getframe", return_value=None): + assert capture_caller_context() is None + + +def test_context_for_frame_uses_lineno_fallback_when_instruction_index_out_of_range(): + frame = _FakeFrame( + filename="/tmp/fake.py", + f_lineno=42, + f_lasti=999, + positions=[], + ) + context = _context_for_frame(frame) + assert context.lineno == 42 + assert context.colno == 1 + assert context.end_colno == 1 + + +def test_context_for_frame_defaults_start_line_when_position_start_line_is_none(): + frame = _FakeFrame( + filename="/tmp/fake.py", + f_lineno=99, + f_lasti=0, + positions=[(None, None, None, None)], + ) + context = _context_for_frame(frame) + assert context.lineno == 99 + + +def test_context_for_frame_defaults_end_line_when_position_end_line_is_none(): + frame = _FakeFrame( + filename="/tmp/fake.py", + f_lineno=7, + f_lasti=0, + positions=[(5, None, 2, 4)], + ) + context = _context_for_frame(frame) + assert context.lineno == 5 + assert context.colno == 3 + assert context.end_colno == 5 + + +def test_context_for_frame_defaults_start_col_when_position_start_col_is_none(): + frame = _FakeFrame( + filename="/tmp/fake.py", + f_lineno=1, + f_lasti=0, + positions=[(1, 1, None, 5)], + ) + context = _context_for_frame(frame) + assert context.colno == 1 + assert context.end_colno == 6 + + +def test_context_for_frame_defaults_end_col_when_position_end_col_is_none(): + frame = _FakeFrame( + filename="/tmp/fake.py", + f_lineno=1, + f_lasti=0, + positions=[(1, 1, 3, None)], + ) + context = _context_for_frame(frame) + assert context.colno == 4 + assert context.end_colno == 4 + + +def test_caller_context_dataclass_is_frozen_and_holds_all_five_fields(): + context = CallerContext(filename="a.py", lineno=1, colno=1, end_colno=5, source_line="x = 1") + assert context.filename == "a.py" + assert context.source_line == "x = 1" diff --git a/tests/errors/errors.test.py b/tests/errors/errors.test.py index 6472469..74acaa4 100644 --- a/tests/errors/errors.test.py +++ b/tests/errors/errors.test.py @@ -35,131 +35,212 @@ from edify.errors.structure import ( def test_start_input_already_defined_outside_subexpression(): error = StartInputAlreadyDefinedError() - assert "already has a start" in str(error) - assert "ignore_start_and_end" not in str(error) + text = str(error) + assert "start_of_input has already been added" in text + assert "help: remove the duplicate .start_of_input()" in text + assert "ignore_start_and_end" not in text def test_start_input_already_defined_in_subexpression(): error = StartInputAlreadyDefinedError(in_subexpression=True) - assert "ignore_start_and_end" in str(error) + text = str(error) + assert "start_of_input has already been added" in text + assert "ignore_start_and_end=True" in text def test_end_input_already_defined_outside_subexpression(): error = EndInputAlreadyDefinedError() - assert "already has an end" in str(error) - assert "ignore_start_and_end" not in str(error) + text = str(error) + assert "end_of_input has already been added" in text + assert "help: remove the duplicate .end_of_input()" in text + assert "ignore_start_and_end" not in text def test_end_input_already_defined_in_subexpression(): error = EndInputAlreadyDefinedError(in_subexpression=True) - assert "ignore_start_and_end" in str(error) + text = str(error) + assert "end_of_input has already been added" in text + assert "ignore_start_and_end=True" in text def test_cannot_define_start_after_end(): error = CannotDefineStartAfterEndError() - assert "start of input after defining an end" in str(error) + text = str(error) + assert "start_of_input cannot follow end_of_input" in text + assert "move .start_of_input() to before" in text -def test_invalid_total_capture_groups_index(): +def test_invalid_total_capture_groups_index_reports_out_of_range(): error = InvalidTotalCaptureGroupsIndexError(5, 3) - assert "Invalid index #5" in str(error) - assert "only 3 capture groups" in str(error) + text = str(error) + assert "back_reference index #5 is out of range" in text + assert "3 capture groups" in text + assert "valid indices are 1 to 3" in text + + +def test_invalid_total_capture_groups_index_with_no_capture_groups_prompts_add_one(): + error = InvalidTotalCaptureGroupsIndexError(1, 0) + text = str(error) + assert "no capture groups yet" in text + assert "add a .capture()" in text def test_must_be_a_string(): error = MustBeAStringError("Name", "int") - assert "Name must be a string" in str(error) - assert "int" in str(error) + text = str(error) + assert "Name must be a string" in text + assert "int" in text + assert "convert the value with str(...)" in text def test_must_be_one_character(): error = MustBeOneCharacterError("Value") - assert "Value must be one character long" in str(error) + text = str(error) + assert "Value must be one character long" in text def test_must_be_single_character(): error = MustBeSingleCharacterError("Value", "str") - assert "Value must be a single character" in str(error) - assert "str" in str(error) + text = str(error) + assert "Value must be a single character" in text + assert "str" in text def test_must_be_positive_integer(): error = MustBePositiveIntegerError("count") - assert "count must be a positive integer" in str(error) + text = str(error) + assert "count must be a positive integer" in text def test_must_be_integer_greater_than_zero(): error = MustBeIntegerGreaterThanZeroError("x") - assert "x must be an integer greater than zero" in str(error) + text = str(error) + assert "x must be an integer greater than zero" in text def test_must_be_instance(): error = MustBeInstanceError("Expression", "str", "RegexBuilder") - assert "Expression must be an instance of RegexBuilder" in str(error) - assert "str" in str(error) + text = str(error) + assert "Expression must be an instance of RegexBuilder" in text + assert "str" in text -def test_must_have_a_smaller_value(): +def test_must_have_a_smaller_value_reports_the_codepoints(): error = MustHaveASmallerValueError("z", "a") - assert "z must have a smaller character value than a" in str(error) + text = str(error) + assert "range bounds are inverted" in text + assert "'z'" in text + assert "'a'" in text + assert "= 122" in text + assert "= 97" in text def test_must_be_less_than(): error = MustBeLessThanError("X", "Y") - assert "X must be less than Y" in str(error) + text = str(error) + assert "X must be less than Y" in text def test_must_be_at_least_two_operands(): error = MustBeAtLeastTwoOperandsError("any_of") - assert "any_of requires at least two operands" in str(error) + text = str(error) + assert "any_of requires at least two operands" in text def test_must_be_at_least_one_literal(): error = MustBeAtLeastOneLiteralError("one_of") - assert "one_of requires at least one literal" in str(error) + text = str(error) + assert "one_of requires at least one literal" in text def test_name_not_valid(): error = NameNotValidError("bad name") - assert "Name bad name is not valid" in str(error) + text = str(error) + assert "'bad name' is not a valid identifier" in text + assert "letters, digits, and underscores" in text def test_cannot_create_duplicate_named_group(): error = CannotCreateDuplicateNamedGroupError("dup") - assert 'Can not create duplicate named group "dup"' in str(error) + text = str(error) + assert "named group 'dup' already exists" in text + assert ".named_back_reference('dup')" in text def test_named_group_does_not_exist(): error = NamedGroupDoesNotExistError("missing") - assert 'Named group "missing" does not exist' in str(error) + text = str(error) + assert "named group 'missing' does not exist" in text + assert ".named_capture('missing')" in text def test_cannot_end_while_building_root_expression(): error = CannotEndWhileBuildingRootExpressionError() - assert "Can not end while building the root expression" in str(error) + text = str(error) + assert "cannot .end() while building the root expression" in text + assert "no matching opener" in text or "no frame to close" in text def test_cannot_call_subexpression(): error = CannotCallSubexpressionError("capture") - assert "Can not call subexpression" in str(error) - assert "capture" in str(error) + text = str(error) + assert "cannot merge a subexpression that has an unclosed frame" in text + assert "capture" in text def test_unknown_element_type(): error = UnknownElementTypeError("WeirdElement") - assert "WeirdElement" in str(error) + text = str(error) + assert "unknown element type 'WeirdElement'" in text def test_non_fusable_element(): error = NonFusableElementError("DigitElement") - assert "Cannot fuse element of type DigitElement" in str(error) + text = str(error) + assert "cannot fuse element 'DigitElement' into a character class" in text def test_unexpected_frame_type(): error = UnexpectedFrameTypeError("DigitElement") - assert "Stack frame anchored at unexpected element type DigitElement" in str(error) + text = str(error) + assert "stack frame anchored at unexpected element 'DigitElement'" in text def test_failed_to_compile_regex(): error = FailedToCompileRegexError("missing )") - assert "Cannot compile regex: missing )" in str(error) + text = str(error) + assert "rejected by the re engine" in text + assert "missing )" in text + + +def test_every_annotated_error_message_starts_with_error_prefix(): + errors = [ + StartInputAlreadyDefinedError(), + CannotDefineStartAfterEndError(), + EndInputAlreadyDefinedError(), + InvalidTotalCaptureGroupsIndexError(1, 0), + MustBeAStringError("X", "int"), + MustBeOneCharacterError("X"), + MustBeSingleCharacterError("X", "int"), + MustBePositiveIntegerError("X"), + MustBeIntegerGreaterThanZeroError("X"), + MustBeInstanceError("X", "int", "Y"), + MustHaveASmallerValueError("z", "a"), + MustBeLessThanError("A", "B"), + MustBeAtLeastTwoOperandsError("f"), + MustBeAtLeastOneLiteralError("f"), + NameNotValidError("x"), + CannotCreateDuplicateNamedGroupError("x"), + NamedGroupDoesNotExistError("x"), + CannotEndWhileBuildingRootExpressionError(), + CannotCallSubexpressionError("capture"), + UnknownElementTypeError("X"), + NonFusableElementError("X"), + UnexpectedFrameTypeError("X"), + FailedToCompileRegexError("boom"), + ] + for error in errors: + text = str(error) + assert text.startswith("error:") + assert "help:" in text + assert "= note:" in text diff --git a/tests/errors/formatting.test.py b/tests/errors/formatting.test.py new file mode 100644 index 0000000..314aff1 --- /dev/null +++ b/tests/errors/formatting.test.py @@ -0,0 +1,127 @@ +"""Tests for the message formatter helpers in :mod:`edify.errors.formatting`.""" + +from unittest import mock + +from edify.errors.context import CallerContext +from edify.errors.formatting import ( + FixInsertion, + Problem, + compose_annotated_message, + format_error, + format_fix_block, + format_help_header, + format_note_line, + format_pointer_block, + format_problem, + format_problem_header, +) + + +def _context(source_line: str = " x = do_the_thing(1, 2, 3)", colno: int = 9) -> CallerContext: + return CallerContext( + filename="/tmp/user_code.py", + lineno=42, + colno=colno, + end_colno=colno + 15, + source_line=source_line, + ) + + +def test_format_error_with_only_summary_returns_bare_header(): + assert format_error("boom") == "error: boom" + + +def test_format_error_with_summary_and_blocks_joins_them_with_blank_line(): + output = format_error("boom", "first block", "second block") + assert output == "error: boom\n\nfirst block\n\nsecond block" + + +def test_format_error_skips_empty_blocks(): + output = format_error("boom", "", "only real block", "") + assert output == "error: boom\n\nonly real block" + + +def test_format_pointer_block_draws_caret_at_caller_column(): + context = _context() + block = format_pointer_block(context, "here") + lines = block.splitlines() + assert lines[0] == " --> /tmp/user_code.py:42:9" + assert "^^^^^^^^^^^^^^^ here" in block + + +def test_format_pointer_block_has_at_least_one_caret_when_span_is_empty(): + context = CallerContext( + filename="/tmp/x.py", + lineno=1, + colno=5, + end_colno=5, + source_line="abcde", + ) + block = format_pointer_block(context, "point") + assert "^ point" in block + + +def test_format_note_line_prepends_the_note_prefix(): + assert format_note_line("something is off") == " = note: something is off" + + +def test_format_problem_header_prepends_problem_prefix(): + assert format_problem_header("stuff") == "problem: stuff" + + +def test_format_help_header_prepends_help_prefix(): + assert format_help_header("do X") == "help: do X" + + +def test_format_fix_block_inserts_text_at_the_target_column(): + context = _context(source_line="value = compute()", colno=1) + insertion = FixInsertion(column=9, text="new_") + block = format_fix_block(context, insertion) + assert "value = new_compute()" in block + assert "++++" in block + + +def test_format_problem_composes_header_pointer_help_and_fix(): + problem_context = _context(source_line="pattern = build()", colno=11) + fix_context = _context(source_line="pattern = build()", colno=11) + fix_insertion = FixInsertion(column=11, text=".digit()") + problem = Problem( + description="missing operand", + problem_context=problem_context, + problem_hint="no operand added", + help_summary="add .digit() before .build()", + fix_context=fix_context, + fix_insertion=fix_insertion, + ) + output = format_problem(problem) + assert "problem: missing operand" in output + assert "no operand added" in output + assert "help: add .digit() before .build()" in output + assert "++++++++" in output + + +def test_compose_annotated_message_when_caller_context_is_available(): + output = compose_annotated_message( + summary="bad thing happened", + trigger_hint="right here", + note="because reasons", + help_line="help: do X", + ) + assert output.startswith("error: bad thing happened") + assert "-->" in output + assert "= note: because reasons" in output + assert output.rstrip().endswith("help: do X") + + +def test_compose_annotated_message_falls_back_when_caller_context_is_none(): + with mock.patch("edify.errors.formatting.capture_caller_context", return_value=None): + output = compose_annotated_message( + summary="bad thing happened", + trigger_hint="here", + note="because reasons", + help_line="help: do X", + ) + assert output.startswith("error: bad thing happened") + assert "-->" not in output + assert "= note: because reasons" in output + assert output.rstrip().endswith("help: do X") diff --git a/tests/errors/quantifier.test.py b/tests/errors/quantifier.test.py index 27b5ddf..1256a68 100644 --- a/tests/errors/quantifier.test.py +++ b/tests/errors/quantifier.test.py @@ -42,14 +42,15 @@ def test_to_regex_raises_when_a_bare_quantifier_has_no_operand(): def test_dangling_message_hints_at_appending_an_operand(): - with pytest.raises(DanglingQuantifierError, match="Append an element"): + with pytest.raises(DanglingQuantifierError, match="append the element"): RegexBuilder().exactly(3).to_regex_string() def test_dangling_quantifier_error_message_contains_expected_text(): error = DanglingQuantifierError() - assert "Dangling quantifier" in str(error) - assert "no operand" in str(error) + text = str(error) + assert "dangling quantifier" in text + assert "no operand" in text def test_stacking_one_or_more_over_exactly_raises(): @@ -84,5 +85,6 @@ def test_a_valid_quantifier_element_quantifier_element_chain_works(): def test_stacked_quantifier_error_message_contains_expected_text(): error = StackedQuantifierError() - assert "stack" in str(error) - assert "pending" in str(error) + text = str(error) + assert "stack a quantifier" in text + assert "pending quantifier" in text |
