"""Step definitions for the TDD expected-fail handler infrastructure tests. These scenarios exercise both ``apply_tdd_inversion`` (the production code path called by the ``Scenario.run()`` monkey-patch) and ``handle_tdd_expected_fail`` (the standalone entry point) using real Behave ``Scenario`` and ``Step`` objects to verify scenario-level and **step-level** status inversion, including guard paths (hook errors, dry-run, non-assertion exceptions). """ from __future__ import annotations from behave import given, then, when from behave.model import Scenario, Status, Step from behave.runner import Context from features.environment import ( _install_tdd_expected_fail_patch, apply_tdd_inversion, before_scenario, handle_tdd_expected_fail, ) _STATUS_MAP: dict[str, Status] = { "failed": Status.failed, "passed": Status.passed, "skipped": Status.skipped, "untested": Status.untested, } @given('a mock scenario tagged "{tags}" with status "{status}"') def step_mock_scenario(context: Context, tags: str, status: str) -> None: """Build a real ``Scenario`` object with the requested tags and status.""" tag_list = [t.lstrip("@") for t in tags.split()] scenario = Scenario( filename="", line=1, keyword="Scenario", name="mock-tdd-scenario", tags=tag_list, ) # Force the desired starting status. scenario.clear_status() scenario.set_status(_STATUS_MAP[status]) # Initialise guard attributes (Behave sets these during Scenario.run()). scenario.hook_failed = False scenario.was_dry_run = False context.mock_scenario = scenario context.mock_steps = {} @given('a mock scenario tagged "{tags}" with status "{status}" and hook_failed') def step_mock_scenario_hook_failed(context: Context, tags: str, status: str) -> None: """Build a real ``Scenario`` with ``hook_failed = True``.""" step_mock_scenario(context, tags, status) context.mock_scenario.hook_failed = True @given('a mock scenario tagged "{tags}" with status "{status}" and was_dry_run') def step_mock_scenario_dry_run(context: Context, tags: str, status: str) -> None: """Build a real ``Scenario`` with ``was_dry_run = True``.""" step_mock_scenario(context, tags, status) context.mock_scenario.was_dry_run = True @given('the mock scenario has a step "{step_name}" with status "{status}"') def step_add_mock_step(context: Context, step_name: str, status: str) -> None: """Add a real ``Step`` to the mock scenario with the requested status.""" step = Step( filename="", line=1, keyword="Given", step_type="given", name=step_name, ) step.status = _STATUS_MAP[status] # Provide an error_message for failed steps so logging can use it. if status == "failed": step.error_message = f"simulated failure in {step_name!r}" step.exception = AssertionError(f"simulated failure in {step_name!r}") context.mock_scenario.steps.append(step) context.mock_steps[step_name] = step @given( 'the mock scenario has an infrastructure-error step "{step_name}"' ' with exception "{exc_type}"' ) def step_add_mock_step_with_exception( context: Context, step_name: str, exc_type: str ) -> None: """Add a real ``Step`` with a specific non-assertion exception type.""" step = Step( filename="", line=1, keyword="Given", step_type="given", name=step_name, ) step.status = Status.failed exc_classes: dict[str, type[BaseException]] = { "RuntimeError": RuntimeError, "TypeError": TypeError, "ConnectionError": ConnectionError, "AssertionError": AssertionError, } exc_cls = exc_classes.get(exc_type, RuntimeError) step.exception = exc_cls(f"simulated {exc_type} in {step_name!r}") step.error_message = f"simulated {exc_type} in {step_name!r}" context.mock_scenario.steps.append(step) context.mock_steps[step_name] = step # --------------------------------------------------------------------------- # apply_tdd_inversion steps # --------------------------------------------------------------------------- @when("apply_tdd_inversion processes the scenario with failed True") def step_run_apply_inversion_failed(context: Context) -> None: """Invoke ``apply_tdd_inversion`` with ``failed=True``.""" context.apply_inversion_result = apply_tdd_inversion( context.mock_scenario, failed=True ) @when("apply_tdd_inversion processes the scenario with failed False") def step_run_apply_inversion_not_failed(context: Context) -> None: """Invoke ``apply_tdd_inversion`` with ``failed=False``.""" context.apply_inversion_result = apply_tdd_inversion( context.mock_scenario, failed=False ) @then("the apply_tdd_inversion result should be False") def step_check_inversion_result_false(context: Context) -> None: """Assert ``apply_tdd_inversion`` returned ``False``.""" assert context.apply_inversion_result is False, ( f"Expected apply_tdd_inversion to return False, " f"got {context.apply_inversion_result!r}" ) @then("the apply_tdd_inversion result should be True") def step_check_inversion_result_true(context: Context) -> None: """Assert ``apply_tdd_inversion`` returned ``True``.""" assert context.apply_inversion_result is True, ( f"Expected apply_tdd_inversion to return True, " f"got {context.apply_inversion_result!r}" ) # --------------------------------------------------------------------------- # handle_tdd_expected_fail steps (standalone entry point) # --------------------------------------------------------------------------- @when("the TDD expected-fail handler processes the scenario") def step_run_handler(context: Context) -> None: """Invoke ``handle_tdd_expected_fail`` on the mock scenario.""" handle_tdd_expected_fail(context.mock_scenario) @then('the scenario status should be "{expected}"') def step_check_scenario_status(context: Context, expected: str) -> None: """Assert the scenario has the expected status after handler processing.""" actual = context.mock_scenario.status assert actual == _STATUS_MAP[expected], ( f"Expected scenario status {expected!r}, got {actual!r}" ) @then('the step "{step_name}" should have status "{expected}"') def step_check_step_status(context: Context, step_name: str, expected: str) -> None: """Assert a named step has the expected status after handler processing.""" step = context.mock_steps[step_name] actual = step.status assert actual == _STATUS_MAP[expected], ( f"Expected step {step_name!r} status {expected!r}, got {actual!r}" ) @then('the step "{step_name}" should have error_message cleared') def step_check_error_message_cleared(context: Context, step_name: str) -> None: """Assert a named step's ``error_message`` is ``None`` after inversion.""" step = context.mock_steps[step_name] assert step.error_message is None, ( f"Expected step {step_name!r} error_message to be None after " f"inversion, got {step.error_message!r}" ) @then('the step "{step_name}" should have a synthetic error_message') def step_check_synthetic_error_message(context: Context, step_name: str) -> None: """Assert a named step's ``error_message`` contains the unexpected-pass text.""" step = context.mock_steps[step_name] assert step.error_message is not None, ( f"Expected step {step_name!r} to have an error_message set, got None" ) assert "Bug appears to be fixed" in step.error_message, ( f"Expected step {step_name!r} error_message to contain " f"'Bug appears to be fixed', got {step.error_message!r}" ) # --------------------------------------------------------------------------- # _install_tdd_expected_fail_patch tests (S2) # --------------------------------------------------------------------------- @when("the TDD expected-fail patch installation status is checked") def step_check_patch_status(context: Context) -> None: """Verify the patch was installed during ``before_all``.""" # _install_tdd_expected_fail_patch is called in before_all, which # has already run by the time any scenario executes. context.patch_flag = getattr(Scenario, "_tdd_run_patched", False) @then("the Scenario class should have _tdd_run_patched set to True") def step_verify_patch_flag(context: Context) -> None: """Assert the patch flag is set on the Scenario class.""" assert context.patch_flag is True, ( "Expected Scenario._tdd_run_patched to be True after " "_install_tdd_expected_fail_patch(), got False" ) @when("_install_tdd_expected_fail_patch is called twice") def step_call_patch_twice(context: Context) -> None: """Call ``_install_tdd_expected_fail_patch`` twice and capture ``run``. Saves the current ``Scenario.run`` before the second call so the ``Then`` step can verify identity. A cleanup handler restores the original method in case the idempotency guard is ever broken — this prevents a double-wrapped ``Scenario.run`` from leaking into subsequent scenarios in the same process. """ run_before = Scenario.run _install_tdd_expected_fail_patch() context.run_after_second_call = Scenario.run context.run_before_second_call = run_before # TF-1 safety net: restore Scenario.run if the guard failed. def _restore_run() -> None: Scenario.run = run_before context._cleanup_handlers.append(_restore_run) @then("the Scenario.run method should not be double-wrapped") def step_verify_no_double_wrap(context: Context) -> None: """Assert a second call to the patch does not double-wrap ``Scenario.run``.""" assert context.run_after_second_call is context.run_before_second_call, ( "Expected Scenario.run to remain the same after a second call to " "_install_tdd_expected_fail_patch() (idempotency), but it was " "replaced — indicating double-wrapping" ) # --------------------------------------------------------------------------- # before_scenario hook_failed regression test (TC-2) # --------------------------------------------------------------------------- @when("before_scenario is called with the mock scenario") def step_call_before_scenario(context: Context) -> None: """Invoke ``before_scenario`` with the mock scenario. Uses a lightweight mock ``context`` so the hook's non-TDD setup (database paths, container overrides, etc.) runs harmlessly. """ from unittest.mock import MagicMock mock_context = MagicMock() mock_context._cleanup_handlers = [] mock_context._scenario_db_paths = [] before_scenario(mock_context, context.mock_scenario) @then("the scenario hook_failed flag should be True") def step_check_hook_failed_true(context: Context) -> None: """Assert the scenario's ``hook_failed`` attribute is ``True``.""" assert context.mock_scenario.hook_failed is True, ( "Expected scenario.hook_failed to be True after before_scenario " "processes invalid TDD tags, got False" )