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
490 lines
16 KiB
Python
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
|