Files
cleveragents-core/features/steps/plan_model_coverage_steps.py
T
freemo 039ab87b52
CI / lint (pull_request) Successful in 15s
CI / typecheck (pull_request) Successful in 31s
CI / security (pull_request) Successful in 22s
CI / quality (pull_request) Successful in 15s
CI / integration_tests (pull_request) Successful in 4m37s
CI / build (pull_request) Successful in 15s
CI / lint (push) Successful in 12s
CI / typecheck (push) Successful in 27s
CI / security (push) Successful in 22s
CI / quality (push) Successful in 15s
CI / unit_tests (pull_request) Successful in 9m56s
CI / integration_tests (push) Successful in 4m41s
CI / build (push) Successful in 15s
CI / unit_tests (push) Successful in 9m58s
CI / coverage (pull_request) Successful in 7m1s
CI / docker (pull_request) Successful in 8s
CI / docker (push) Successful in 8s
CI / coverage (push) Successful in 6m54s
feat(domain): rebaseline plan phases and apply states
2026-02-15 00:12:07 -05:00

490 lines
16 KiB
Python

"""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