0dcf7bf152
Before transitioning to the Apply phase, PlanLifecycleService.apply_plan now evaluates the plan's definition_of_done criteria using DoDEvaluator (TextMatchEvaluator). If any required criteria fail, a DoDGatingError is raised and the plan remains in Execute/COMPLETE state. The evaluation result is stored in plan.validation_summary with dod_evaluated=True and dod_all_passed reflecting the outcome. Plans with no DoD text skip evaluation and proceed normally. Adds DoDGatingError exception class and _evaluate_dod() helper method. Adds BDD feature file and step definitions for DoD gating scenarios. ISSUES CLOSED: #7927
313 lines
12 KiB
Python
313 lines
12 KiB
Python
"""Step definitions for Definition-of-Done gating in the Apply phase.
|
|
|
|
Tests that ``PlanLifecycleService.apply_plan`` evaluates DoD criteria
|
|
before transitioning to the Apply phase and blocks the transition when
|
|
any required criteria fail.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.application.services.plan_lifecycle_service import (
|
|
DoDGatingError,
|
|
PlanLifecycleService,
|
|
)
|
|
from cleveragents.config.settings import Settings
|
|
from cleveragents.domain.models.core.plan import (
|
|
PlanPhase,
|
|
ProjectLink,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background / setup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("I have a plan lifecycle service for DoD gating")
|
|
def step_create_dod_lifecycle_service(context: Context) -> None:
|
|
"""Create a fresh PlanLifecycleService for DoD gating tests."""
|
|
settings = Settings()
|
|
context.dod_service = PlanLifecycleService(settings=settings)
|
|
context.dod_plan = None
|
|
context.dod_error = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan creation helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _create_plan_in_execute_complete(
|
|
context: Context,
|
|
override_dod: str | None = None,
|
|
override_arguments: dict[str, str] | None = None,
|
|
) -> None:
|
|
"""Create an action + plan and drive it to Execute/COMPLETE state.
|
|
|
|
The action is always created with a valid DoD text. After plan
|
|
creation, the plan's ``definition_of_done`` and ``arguments`` fields
|
|
are overridden directly so tests can exercise arbitrary DoD text and
|
|
argument combinations without needing to declare them on the action.
|
|
"""
|
|
service: PlanLifecycleService = context.dod_service
|
|
|
|
action = service.create_action(
|
|
name="local/dod-test-action",
|
|
description="DoD gating test action",
|
|
definition_of_done="placeholder definition of done",
|
|
strategy_actor="openai/gpt-4",
|
|
execution_actor="openai/gpt-4",
|
|
)
|
|
|
|
plan = service.use_action(
|
|
action_name=str(action.namespaced_name),
|
|
project_links=[ProjectLink(project_name="test-project")],
|
|
)
|
|
|
|
# Override DoD and arguments directly on the plan object so tests
|
|
# can exercise arbitrary combinations without action-level validation.
|
|
if override_dod is not None:
|
|
plan.definition_of_done = override_dod
|
|
if override_arguments is not None:
|
|
plan.arguments = override_arguments
|
|
|
|
# Drive to Execute/COMPLETE
|
|
service.start_strategize(plan.identity.plan_id)
|
|
service.complete_strategize(plan.identity.plan_id)
|
|
service.execute_plan(plan.identity.plan_id)
|
|
service.start_execute(plan.identity.plan_id)
|
|
service.complete_execute(plan.identity.plan_id)
|
|
|
|
context.dod_plan = service.get_plan(plan.identity.plan_id)
|
|
|
|
|
|
@given("I have a plan with no definition of done in execute complete state")
|
|
def step_plan_no_dod(context: Context) -> None:
|
|
"""Create a plan with definition_of_done cleared to empty string."""
|
|
_create_plan_in_execute_complete(context, override_dod="")
|
|
|
|
|
|
@given("I have a plan with empty definition of done in execute complete state")
|
|
def step_plan_empty_dod(context: Context) -> None:
|
|
"""Create a plan with definition_of_done set to whitespace only."""
|
|
_create_plan_in_execute_complete(context, override_dod=" ")
|
|
|
|
|
|
@given(
|
|
'I have a plan with definition of done "{dod_text}" and argument "{arg_key}" equal to "{arg_value}"'
|
|
)
|
|
def step_plan_with_dod_and_arg(
|
|
context: Context, dod_text: str, arg_key: str, arg_value: str
|
|
) -> None:
|
|
"""Create a plan with a specific DoD text and a single argument."""
|
|
_create_plan_in_execute_complete(
|
|
context,
|
|
override_dod=dod_text,
|
|
override_arguments={arg_key: arg_value},
|
|
)
|
|
|
|
|
|
@given('I have a plan with definition of done "{dod_text}" and no arguments')
|
|
def step_plan_with_dod_no_args(context: Context, dod_text: str) -> None:
|
|
"""Create a plan with a specific DoD text and no arguments."""
|
|
# Replace literal \n with actual newlines for multi-line DoD
|
|
actual_dod = dod_text.replace("\\n", "\n")
|
|
_create_plan_in_execute_complete(
|
|
context,
|
|
override_dod=actual_dod,
|
|
override_arguments={},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Apply actions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I apply the plan with DoD gating")
|
|
def step_apply_plan_dod(context: Context) -> None:
|
|
"""Apply the plan (expecting success)."""
|
|
service: PlanLifecycleService = context.dod_service
|
|
plan_id = context.dod_plan.identity.plan_id
|
|
context.dod_error = None
|
|
context.dod_plan = service.apply_plan(plan_id)
|
|
|
|
|
|
@when("I try to apply the plan with DoD gating")
|
|
def step_try_apply_plan_dod(context: Context) -> None:
|
|
"""Attempt to apply the plan, capturing DoDGatingError if raised."""
|
|
service: PlanLifecycleService = context.dod_service
|
|
plan_id = context.dod_plan.identity.plan_id
|
|
context.dod_error = None
|
|
try:
|
|
context.dod_plan = service.apply_plan(plan_id)
|
|
except DoDGatingError as exc:
|
|
context.dod_error = exc
|
|
# Refresh plan state from service after the blocked attempt
|
|
context.dod_plan = service.get_plan(plan_id)
|
|
|
|
|
|
@when("I start the apply phase")
|
|
def step_start_apply_dod(context: Context) -> None:
|
|
"""Start the Apply phase processing."""
|
|
service: PlanLifecycleService = context.dod_service
|
|
plan_id = context.dod_plan.identity.plan_id
|
|
context.dod_plan = service.start_apply(plan_id)
|
|
|
|
|
|
@when("I complete the apply phase")
|
|
def step_complete_apply_dod(context: Context) -> None:
|
|
"""Complete the Apply phase."""
|
|
service: PlanLifecycleService = context.dod_service
|
|
plan_id = context.dod_plan.identity.plan_id
|
|
context.dod_plan = service.complete_apply(plan_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the plan should transition to apply phase")
|
|
def step_assert_plan_in_apply(context: Context) -> None:
|
|
"""Assert the plan is now in the Apply phase."""
|
|
plan = context.dod_plan
|
|
assert plan.phase == PlanPhase.APPLY, (
|
|
f"Expected plan phase 'apply', got '{plan.phase.value}'"
|
|
)
|
|
|
|
|
|
@then("the plan should remain in execute phase")
|
|
def step_assert_plan_in_execute(context: Context) -> None:
|
|
"""Assert the plan is still in the Execute phase."""
|
|
plan = context.dod_plan
|
|
assert plan.phase == PlanPhase.EXECUTE, (
|
|
f"Expected plan phase 'execute', got '{plan.phase.value}'"
|
|
)
|
|
|
|
|
|
@then('the dod plan phase should be "{expected_phase}"')
|
|
def step_assert_dod_plan_phase(context: Context, expected_phase: str) -> None:
|
|
"""Assert the DoD plan phase matches the expected value."""
|
|
plan = context.dod_plan
|
|
assert plan.phase.value == expected_phase, (
|
|
f"Expected plan phase '{expected_phase}', got '{plan.phase.value}'"
|
|
)
|
|
|
|
|
|
@then('the dod plan processing state should be "{expected_state}"')
|
|
def step_assert_dod_plan_processing_state(
|
|
context: Context, expected_state: str
|
|
) -> None:
|
|
"""Assert the DoD plan processing state matches the expected value."""
|
|
plan = context.dod_plan
|
|
assert plan.processing_state.value == expected_state, (
|
|
f"Expected processing state '{expected_state}', "
|
|
f"got '{plan.processing_state.value}'"
|
|
)
|
|
|
|
|
|
@then("the plan validation summary should be empty")
|
|
def step_assert_validation_summary_empty(context: Context) -> None:
|
|
"""Assert the plan's validation_summary is None (no DoD evaluated)."""
|
|
plan = context.dod_plan
|
|
assert plan.validation_summary is None, (
|
|
f"Expected validation_summary to be None, got {plan.validation_summary!r}"
|
|
)
|
|
|
|
|
|
@then("the plan validation summary should record dod_evaluated as true")
|
|
def step_assert_dod_evaluated_true(context: Context) -> None:
|
|
"""Assert validation_summary has dod_evaluated=True."""
|
|
plan = context.dod_plan
|
|
assert plan.validation_summary is not None, (
|
|
"Expected validation_summary to be set, but it is None"
|
|
)
|
|
assert plan.validation_summary.get("dod_evaluated") is True, (
|
|
f"Expected dod_evaluated=True, got {plan.validation_summary!r}"
|
|
)
|
|
|
|
|
|
@then("the plan validation summary should record dod_all_passed as true")
|
|
def step_assert_dod_all_passed_true(context: Context) -> None:
|
|
"""Assert validation_summary has dod_all_passed=True."""
|
|
plan = context.dod_plan
|
|
assert plan.validation_summary is not None, (
|
|
"Expected validation_summary to be set, but it is None"
|
|
)
|
|
assert plan.validation_summary.get("dod_all_passed") is True, (
|
|
f"Expected dod_all_passed=True, got {plan.validation_summary!r}"
|
|
)
|
|
|
|
|
|
@then("the plan validation summary should record dod_all_passed as false")
|
|
def step_assert_dod_all_passed_false(context: Context) -> None:
|
|
"""Assert validation_summary has dod_all_passed=False."""
|
|
plan = context.dod_plan
|
|
assert plan.validation_summary is not None, (
|
|
"Expected validation_summary to be set, but it is None"
|
|
)
|
|
assert plan.validation_summary.get("dod_all_passed") is False, (
|
|
f"Expected dod_all_passed=False, got {plan.validation_summary!r}"
|
|
)
|
|
|
|
|
|
@then("a DoD gating error should be raised")
|
|
def step_assert_dod_gating_error(context: Context) -> None:
|
|
"""Assert a DoDGatingError was raised."""
|
|
assert context.dod_error is not None, (
|
|
"Expected a DoDGatingError to be raised, but no error was captured"
|
|
)
|
|
assert isinstance(context.dod_error, DoDGatingError), (
|
|
f"Expected DoDGatingError, got {type(context.dod_error).__name__}"
|
|
)
|
|
|
|
|
|
@then(
|
|
"the DoD gating error should report {failed:d} failed criteria out of {total:d} total"
|
|
)
|
|
def step_assert_dod_error_counts(context: Context, failed: int, total: int) -> None:
|
|
"""Assert the DoDGatingError has the expected failure counts."""
|
|
error = context.dod_error
|
|
assert isinstance(error, DoDGatingError), (
|
|
f"Expected DoDGatingError, got {type(error).__name__}"
|
|
)
|
|
assert error.failed_count == failed, (
|
|
f"Expected failed_count={failed}, got {error.failed_count}"
|
|
)
|
|
assert error.total_count == total, (
|
|
f"Expected total_count={total}, got {error.total_count}"
|
|
)
|
|
|
|
|
|
@then('the plan validation summary should contain key "{key}" with value true')
|
|
def step_assert_validation_summary_key_true(context: Context, key: str) -> None:
|
|
"""Assert validation_summary contains a key with value True."""
|
|
plan = context.dod_plan
|
|
assert plan.validation_summary is not None, (
|
|
"Expected validation_summary to be set, but it is None"
|
|
)
|
|
assert key in plan.validation_summary, (
|
|
f"Expected key '{key}' in validation_summary, "
|
|
f"got keys: {list(plan.validation_summary.keys())}"
|
|
)
|
|
assert plan.validation_summary[key] is True, (
|
|
f"Expected validation_summary['{key}']=True, "
|
|
f"got {plan.validation_summary[key]!r}"
|
|
)
|
|
|
|
|
|
@then('the plan validation summary should contain key "{key}"')
|
|
def step_assert_validation_summary_has_key(context: Context, key: str) -> None:
|
|
"""Assert validation_summary contains a specific key."""
|
|
plan = context.dod_plan
|
|
assert plan.validation_summary is not None, (
|
|
"Expected validation_summary to be set, but it is None"
|
|
)
|
|
assert key in plan.validation_summary, (
|
|
f"Expected key '{key}' in validation_summary, "
|
|
f"got keys: {list(plan.validation_summary.keys())}"
|
|
)
|