"""Step definitions for plan_model_coverage.feature. Covers ProjectLink validation, as_cli_dict output, APPLIED auto-correction, and NamespacedName edge cases that were previously untested. """ from behave import given, then, when from behave.runner import Context from pydantic import ValidationError from cleveragents.domain.models.core.plan import ( AutomationProfileProvenance, AutomationProfileRef, InvariantSource, NamespacedName, Plan, PlanIdentity, PlanInvariant, PlanPhase, ProcessingState, ProjectLink, SubplanStatus, ) # Reusable ULID constants ULID_MAIN = "01ARZ3NDEKTSV4RRFFQ69G5FAV" ULID_PARENT = "01ARZ3NDEKTSV4RRFFQ69G5FAW" ULID_CHILD = "01ARZ3NDEKTSV4RRFFQ69G5FAX" def _base_plan(**overrides: object) -> Plan: """Create a minimal Plan with overrides for testing.""" defaults: dict[str, object] = { "identity": PlanIdentity(plan_id=ULID_MAIN), "namespaced_name": NamespacedName( server=None, namespace="local", name="test-plan" ), "description": "Test plan for CLI output", "action_name": "local/test-action", "phase": PlanPhase.STRATEGIZE, "processing_state": ProcessingState.QUEUED, } defaults.update(overrides) return Plan(**defaults) # type: ignore[arg-type] # ---- ProjectLink.validate_alias classmethod ---- @when('I validate the project link alias "{alias}"') def step_validate_alias(context: Context, alias: str) -> None: """Call ProjectLink.validate_alias with the given alias.""" context.alias_valid = ProjectLink.validate_alias(alias) @when("I validate an empty project link alias") def step_validate_empty_alias(context: Context) -> None: """Call ProjectLink.validate_alias with an empty string.""" context.alias_valid = ProjectLink.validate_alias("") @then("the alias should be valid") def step_alias_valid(context: Context) -> None: """Assert the alias validation returned True.""" assert context.alias_valid is True, "Expected alias to be valid" @then("the alias should be invalid") def step_alias_invalid(context: Context) -> None: """Assert the alias validation returned False.""" assert context.alias_valid is False, "Expected alias to be invalid" # ---- ProjectLink construction with alias validator ---- @when('I try to create a project link with alias "{alias}"') def step_try_create_project_link_invalid(context: Context, alias: str) -> None: """Attempt to construct a ProjectLink that may fail validation.""" context.error = None try: context.project_link = ProjectLink( project_name="local/api-service", alias=alias, ) except ValidationError as e: context.error = e @when('I create a project link with alias "{alias}"') def step_create_project_link(context: Context, alias: str) -> None: """Construct a ProjectLink with a valid alias.""" context.project_link = ProjectLink( project_name="local/api-service", alias=alias, ) @then('the project link alias should be "{expected}"') def step_check_project_link_alias(context: Context, expected: str) -> None: """Assert the project link alias matches expected.""" assert context.project_link.alias == expected, ( f"Expected alias '{expected}', got '{context.project_link.alias}'" ) # ---- APPLY phase with APPLIED processing_state is terminal ---- @when("I create a plan in apply phase with applied processing state") def step_create_apply_phase_applied_state(context: Context) -> None: """Create a plan in APPLY phase with APPLIED processing state (terminal).""" context.plan = _base_plan( phase=PlanPhase.APPLY, processing_state=ProcessingState.APPLIED, ) # ---- Duplicate project link alias uniqueness ---- @when("I try to create a plan with duplicate project link aliases") def step_try_duplicate_aliases(context: Context) -> None: """Attempt to create a plan with two project links sharing the same alias.""" context.error = None try: context.plan = _base_plan( project_links=[ ProjectLink(project_name="local/svc-a", alias="main"), ProjectLink(project_name="local/svc-b", alias="main"), ], ) except ValidationError as e: context.error = e @when("I create a plan with distinct project link aliases") def step_create_distinct_aliases(context: Context) -> None: """Create a plan with two project links that have distinct aliases.""" context.plan = _base_plan( project_links=[ ProjectLink(project_name="local/svc-a", alias="primary"), ProjectLink(project_name="local/svc-b", alias="secondary"), ], ) @then("the plan should have {count:d} project links") def step_check_project_link_count(context: Context, count: int) -> None: """Assert the plan has exactly the expected number of project links.""" actual = len(context.plan.project_links) assert actual == count, f"Expected {count} project links, got {actual}" # ---- as_cli_dict — plan creation helpers ---- @given("I create a plan for CLI output") def step_create_plan_cli_base(context: Context) -> None: """Create a minimal plan for CLI dict testing (no optional fields).""" context.plan = _base_plan() @given('I create a plan with definition of done "{dod}"') def step_create_plan_with_dod(context: Context, dod: str) -> None: """Create a plan with a definition_of_done.""" context.plan = _base_plan(definition_of_done=dod) @given('I create a plan with automation profile "{profile}" from "{source}"') def step_create_plan_with_automation_profile( context: Context, profile: str, source: str ) -> None: """Create a plan with an automation profile and provenance tag.""" context.plan = _base_plan( automation_profile=AutomationProfileRef( profile_name=profile, provenance=AutomationProfileProvenance(source), ), ) @given("I create a plan with project links") def step_create_plan_with_project_links(context: Context) -> None: """Create a plan with two project links (one with alias, one read_only).""" context.plan = _base_plan( project_links=[ ProjectLink(project_name="local/api-svc", alias="api", read_only=False), ProjectLink(project_name="local/docs", read_only=True), ], ) @given("I create a plan with invariants") def step_create_plan_with_invariants(context: Context) -> None: """Create a plan with two invariants from different sources.""" context.plan = _base_plan( invariants=[ PlanInvariant( text="Do not modify public API", source=InvariantSource.ACTION ), PlanInvariant(text="Keep backward compat", source=InvariantSource.PROJECT), ], ) @given("I create a plan with arguments") def step_create_plan_with_arguments(context: Context) -> None: """Create a plan with arguments and argument ordering.""" context.plan = _base_plan( arguments={"target": "src/main.py", "mode": "strict"}, arguments_order=["target", "mode"], ) @given('I create a plan with strategy actor "{actor}"') def step_create_plan_with_strategy_actor(context: Context, actor: str) -> None: """Create a plan with strategy_actor set.""" context.plan = _base_plan(strategy_actor=actor) @given('I create a plan with execution actor "{actor}"') def step_create_plan_with_execution_actor(context: Context, actor: str) -> None: """Create a plan with execution_actor set.""" context.plan = _base_plan(execution_actor=actor) @given('I create a plan with error message "{msg}"') def step_create_plan_with_error(context: Context, msg: str) -> None: """Create a plan with error_message set.""" context.plan = _base_plan( processing_state=ProcessingState.ERRORED, error_message=msg, ) @given("I create a subplan for CLI output") def step_create_subplan_cli(context: Context) -> None: """Create a plan that is a subplan (has parent_plan_id).""" context.plan = _base_plan( identity=PlanIdentity( plan_id=ULID_CHILD, parent_plan_id=ULID_PARENT, ), ) @given("I create a plan with subplan statuses") def step_create_plan_with_subplan_statuses(context: Context) -> None: """Create a plan that has tracked subplan statuses.""" context.plan = _base_plan( subplan_statuses=[ SubplanStatus( subplan_id=ULID_CHILD, action_name="local/sub-action", ), ], ) # ---- as_cli_dict invocation ---- @when("I call as_cli_dict") def step_call_as_cli_dict(context: Context) -> None: """Call as_cli_dict() and store the result.""" context.cli_dict = context.plan.as_cli_dict() # ---- as_cli_dict assertions ---- @then('the CLI dict should contain key "{key}"') def step_cli_dict_has_key(context: Context, key: str) -> None: """Assert the CLI dict contains the specified key.""" assert key in context.cli_dict, ( f"Expected key '{key}' in CLI dict, got keys: {list(context.cli_dict.keys())}" ) @then('the CLI dict should not contain key "{key}"') def step_cli_dict_missing_key(context: Context, key: str) -> None: """Assert the CLI dict does not contain the specified key.""" assert key not in context.cli_dict, ( f"Key '{key}' should not be in CLI dict but it is: {context.cli_dict[key]}" ) @then('the CLI dict key "{key}" should be "{expected}"') def step_cli_dict_key_equals(context: Context, key: str, expected: str) -> None: """Assert a CLI dict string value matches expected.""" actual = context.cli_dict[key] assert str(actual) == expected, ( f"Expected CLI dict['{key}'] == '{expected}', got '{actual}'" ) @then('the CLI dict key "{key}" should be {expected:d}') def step_cli_dict_key_equals_int(context: Context, key: str, expected: int) -> None: """Assert a CLI dict integer value matches expected.""" actual = context.cli_dict[key] assert actual == expected, f"Expected CLI dict['{key}'] == {expected}, got {actual}" @then('the CLI dict key "{key}" should be a list of length {length:d}') def step_cli_dict_list_length(context: Context, key: str, length: int) -> None: """Assert a CLI dict value is a list with the expected length.""" value = context.cli_dict[key] assert isinstance(value, list), f"Expected list for '{key}', got {type(value)}" assert len(value) == length, ( f"Expected '{key}' list length {length}, got {len(value)}" ) @then("the CLI dict arguments should preserve order") def step_cli_dict_arguments_order(context: Context) -> None: """Assert arguments dict preserves the defined order.""" args = context.cli_dict["arguments"] keys = list(args.keys()) assert keys == ["target", "mode"], f"Expected order ['target', 'mode'], got {keys}" # ---- is_subplan / is_root_plan / depth properties ---- @given("I create a child plan with a parent") def step_create_child_plan(context: Context) -> None: """Create a plan that has a parent_plan_id set.""" context.plan = _base_plan( identity=PlanIdentity( plan_id=ULID_CHILD, parent_plan_id=ULID_PARENT, root_plan_id=ULID_MAIN, ), ) @then("the plan should not be a subplan") def step_plan_not_subplan(context: Context) -> None: assert not context.plan.is_subplan @then("the plan should be a subplan") def step_plan_is_subplan(context: Context) -> None: assert context.plan.is_subplan @then("the plan should be a root plan") def step_plan_is_root(context: Context) -> None: assert context.plan.is_root_plan @then("the plan should not be a root plan") def step_plan_not_root(context: Context) -> None: assert not context.plan.is_root_plan @then("the plan depth should be {expected:d}") def step_plan_depth(context: Context, expected: int) -> None: assert context.plan.depth == expected, ( f"Expected depth {expected}, got {context.plan.depth}" ) # ---- SubplanFailureHandler ---- @given("a subplan config with fail_fast enabled and sequential mode") def step_config_failfast_sequential(context: Context) -> None: from cleveragents.domain.models.core.plan import ExecutionMode, SubplanConfig context.subplan_config = SubplanConfig( execution_mode=ExecutionMode.SEQUENTIAL, fail_fast=True, ) @given("a subplan config with fail_fast disabled and sequential mode") def step_config_no_failfast_sequential(context: Context) -> None: from cleveragents.domain.models.core.plan import ExecutionMode, SubplanConfig context.subplan_config = SubplanConfig( execution_mode=ExecutionMode.SEQUENTIAL, fail_fast=False, ) @given("a subplan config with fail_fast disabled and parallel mode") def step_config_no_failfast_parallel(context: Context) -> None: from cleveragents.domain.models.core.plan import ExecutionMode, SubplanConfig context.subplan_config = SubplanConfig( execution_mode=ExecutionMode.PARALLEL, fail_fast=False, ) @given("a failed subplan status") def step_failed_subplan_status(context: Context) -> None: context.subplan_status = SubplanStatus( subplan_id=ULID_CHILD, action_name="local/test-action", status=ProcessingState.ERRORED, error="SomeError", ) @given('a failed subplan status with error "{error}"') def step_failed_subplan_status_with_error(context: Context, error: str) -> None: context.subplan_status = SubplanStatus( subplan_id=ULID_CHILD, action_name="local/test-action", status=ProcessingState.ERRORED, error=error, ) @given('a failed subplan status on attempt {attempt:d} with error "{error}"') def step_failed_subplan_status_attempt( context: Context, attempt: int, error: str ) -> None: context.subplan_status = SubplanStatus( subplan_id=ULID_CHILD, action_name="local/test-action", status=ProcessingState.ERRORED, error=error, attempt_number=attempt, ) @given("a subplan config with retry disabled") def step_config_retry_disabled(context: Context) -> None: from cleveragents.domain.models.core.plan import SubplanConfig context.subplan_config = SubplanConfig(retry_failed=False) @given("a subplan config with max retries {n:d}") def step_config_max_retries(context: Context, n: int) -> None: from cleveragents.domain.models.core.plan import SubplanConfig context.subplan_config = SubplanConfig(retry_failed=True, max_retries=n) @given("a subplan config with retry enabled and max retries {n:d}") def step_config_retry_enabled(context: Context, n: int) -> None: from cleveragents.domain.models.core.plan import SubplanConfig context.subplan_config = SubplanConfig(retry_failed=True, max_retries=n) @when("I check should_stop_others") def step_check_should_stop_others(context: Context) -> None: from cleveragents.domain.models.core.plan import SubplanFailureHandler handler = SubplanFailureHandler() context.stop_result = handler.should_stop_others( context.subplan_config, context.subplan_status ) @when("I check should_retry") def step_check_should_retry(context: Context) -> None: from cleveragents.domain.models.core.plan import SubplanFailureHandler handler = SubplanFailureHandler() context.retry_result = handler.should_retry( context.subplan_config, context.subplan_status ) @then("should_stop_others should be true") def step_stop_others_true(context: Context) -> None: assert context.stop_result is True @then("should_stop_others should be false") def step_stop_others_false(context: Context) -> None: assert context.stop_result is False @then("should_retry should be true") def step_retry_true(context: Context) -> None: assert context.retry_result is True @then("should_retry should be false") def step_retry_false(context: Context) -> None: assert context.retry_result is False