fix(decision_service): stabilize dependency persistence tests
CI / lint (pull_request) Successful in 57s
CI / quality (pull_request) Successful in 56s
CI / typecheck (pull_request) Successful in 1m0s
CI / security (pull_request) Successful in 1m30s
CI / push-validation (pull_request) Successful in 31s
CI / helm (pull_request) Successful in 31s
CI / build (pull_request) Successful in 36s
CI / unit_tests (pull_request) Successful in 5m32s
CI / docker (pull_request) Successful in 1m48s
CI / integration_tests (pull_request) Successful in 8m32s
CI / coverage (pull_request) Failing after 9m55s
CI / status-check (pull_request) Failing after 3s
CI / lint (pull_request) Successful in 57s
CI / quality (pull_request) Successful in 56s
CI / typecheck (pull_request) Successful in 1m0s
CI / security (pull_request) Successful in 1m30s
CI / push-validation (pull_request) Successful in 31s
CI / helm (pull_request) Successful in 31s
CI / build (pull_request) Successful in 36s
CI / unit_tests (pull_request) Successful in 5m32s
CI / docker (pull_request) Successful in 1m48s
CI / integration_tests (pull_request) Successful in 8m32s
CI / coverage (pull_request) Failing after 9m55s
CI / status-check (pull_request) Failing after 3s
Split the decision dependency Behave steps below the 500-line limit, add the missing workflow/setup steps, and preserve self-loop constraint failures while keeping duplicate dependency edges idempotent. ISSUES CLOSED: #7926
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
"""Basic steps for decision dependency persistence feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.core.exceptions import DatabaseError
|
||||
from cleveragents.infrastructure.database.models import DecisionDependencyModel
|
||||
|
||||
from features.steps.helpers.decision_dependency_helpers import (
|
||||
PLAN_ID_1,
|
||||
PLAN_ID_2,
|
||||
create_child,
|
||||
create_prerequisite_action,
|
||||
create_prerequisite_plan,
|
||||
create_root,
|
||||
get_edges,
|
||||
record_dependency,
|
||||
setup_db,
|
||||
)
|
||||
|
||||
|
||||
@given("a fresh database for decision dependencies")
|
||||
def step_fresh_db(context: Context) -> None:
|
||||
"""Set up a fresh database."""
|
||||
setup_db(context)
|
||||
|
||||
|
||||
@given('a prerequisite action "{action_name}" exists for dependencies')
|
||||
def step_prereq_action(context: Context, action_name: str) -> None:
|
||||
"""Create a prerequisite action."""
|
||||
create_prerequisite_action(context, action_name)
|
||||
|
||||
|
||||
@given("a prerequisite plan exists for dependencies")
|
||||
def step_prereq_plan(context: Context) -> None:
|
||||
"""Create a prerequisite plan."""
|
||||
create_prerequisite_plan(context, PLAN_ID_1)
|
||||
|
||||
|
||||
@given("a persisted root decision for dependencies")
|
||||
def step_persisted_root_dep(context: Context) -> None:
|
||||
"""Create and persist a root decision."""
|
||||
root = create_root(context)
|
||||
context._ddp_root_id = root.decision_id
|
||||
context._ddp_root_decision = root
|
||||
|
||||
|
||||
@given("a persisted child decision for dependencies")
|
||||
def step_persisted_child_dep(context: Context) -> None:
|
||||
"""Create and persist a child decision."""
|
||||
root_id = getattr(context, "_ddp_root_id", None)
|
||||
if root_id is None:
|
||||
root_id = context._ddp_root_ids[0]
|
||||
child = create_child(context, root_id)
|
||||
context._ddp_child_id = child.decision_id
|
||||
context._ddp_child_decision = child
|
||||
|
||||
|
||||
@given("{count:d} persisted sibling decisions for dependencies")
|
||||
def step_persisted_siblings_dep(context: Context, count: int) -> None:
|
||||
"""Create and persist multiple sibling decisions."""
|
||||
context._ddp_sibling_ids = []
|
||||
for index in range(count):
|
||||
sibling = create_child(
|
||||
context,
|
||||
context._ddp_root_id,
|
||||
sequence_number=index + 1,
|
||||
)
|
||||
context._ddp_sibling_ids.append(sibling.decision_id)
|
||||
|
||||
|
||||
@given("{count:d} persisted root decisions for dependencies")
|
||||
def step_persisted_roots_dep(context: Context, count: int) -> None:
|
||||
"""Create and persist multiple root decisions."""
|
||||
context._ddp_root_ids = []
|
||||
for index in range(count):
|
||||
root = create_root(context, index=index)
|
||||
context._ddp_root_ids.append(root.decision_id)
|
||||
context._ddp_root_id = context._ddp_root_ids[0]
|
||||
|
||||
|
||||
@given("{count:d} persisted plans for dependencies")
|
||||
def step_persisted_plans_dep(context: Context, count: int) -> None:
|
||||
"""Create and persist multiple plans."""
|
||||
context._ddp_plan_ids = [PLAN_ID_1]
|
||||
if count > 1:
|
||||
create_prerequisite_plan(context, PLAN_ID_2)
|
||||
context._ddp_plan_ids.append(PLAN_ID_2)
|
||||
|
||||
|
||||
@given("a decision tree in plan 1 with dependencies")
|
||||
def step_decision_tree_plan1_dep(context: Context) -> None:
|
||||
"""Create a decision tree in plan 1 with dependencies."""
|
||||
root = create_root(context, PLAN_ID_1)
|
||||
child = create_child(context, root.decision_id, PLAN_ID_1)
|
||||
record_dependency(context, root.decision_id, child.decision_id)
|
||||
context._ddp_plan1_root_id = root.decision_id
|
||||
context._ddp_plan1_child_id = child.decision_id
|
||||
|
||||
|
||||
@given("a decision tree in plan 2 with dependencies")
|
||||
def step_decision_tree_plan2_dep(context: Context) -> None:
|
||||
"""Create a decision tree in plan 2 with dependencies."""
|
||||
root = create_root(context, PLAN_ID_2)
|
||||
child = create_child(context, root.decision_id, PLAN_ID_2)
|
||||
record_dependency(context, root.decision_id, child.decision_id)
|
||||
context._ddp_plan2_root_id = root.decision_id
|
||||
context._ddp_plan2_child_id = child.decision_id
|
||||
|
||||
|
||||
@given("a decision in plan 1 and a decision in plan 2")
|
||||
def step_cross_plan_decisions_dep(context: Context) -> None:
|
||||
"""Create decisions in different plans."""
|
||||
d1 = create_root(context, PLAN_ID_1)
|
||||
d2 = create_root(context, PLAN_ID_2)
|
||||
context._ddp_cross_plan_id1 = d1.decision_id
|
||||
context._ddp_cross_plan_id2 = d2.decision_id
|
||||
|
||||
|
||||
@given("a recorded dependency from root to child")
|
||||
def step_given_recorded_dependency_root_child(context: Context) -> None:
|
||||
"""Record a dependency from root to child during scenario setup."""
|
||||
record_dependency(context, context._ddp_root_id, context._ddp_child_id)
|
||||
|
||||
|
||||
@given("recorded dependencies from root to both siblings")
|
||||
def step_given_recorded_dependencies_root_siblings(context: Context) -> None:
|
||||
"""Record dependencies from root to all siblings during setup."""
|
||||
for sibling_id in context._ddp_sibling_ids:
|
||||
record_dependency(context, context._ddp_root_id, sibling_id)
|
||||
|
||||
|
||||
@given("recorded dependencies from both roots to the child")
|
||||
def step_given_recorded_dependencies_both_roots(context: Context) -> None:
|
||||
"""Record dependencies from each root to the child during setup."""
|
||||
for root_id in context._ddp_root_ids:
|
||||
record_dependency(context, root_id, context._ddp_child_id)
|
||||
|
||||
|
||||
@when("I record a dependency from the root to the child")
|
||||
def step_record_dependency_root_child(context: Context) -> None:
|
||||
"""Record a dependency from root to child."""
|
||||
record_dependency(context, context._ddp_root_id, context._ddp_child_id)
|
||||
|
||||
|
||||
@when("I record dependencies from the root to both siblings")
|
||||
def step_record_dependencies_root_siblings(context: Context) -> None:
|
||||
"""Record dependencies from root to all siblings."""
|
||||
for sibling_id in context._ddp_sibling_ids:
|
||||
record_dependency(context, context._ddp_root_id, sibling_id)
|
||||
|
||||
|
||||
@when('I record a dependency with relationship_type "{rel_type}"')
|
||||
def step_record_dependency_custom_type(context: Context, rel_type: str) -> None:
|
||||
"""Record a dependency with custom relationship type."""
|
||||
record_dependency(
|
||||
context,
|
||||
context._ddp_root_id,
|
||||
context._ddp_child_id,
|
||||
relationship_type=rel_type,
|
||||
)
|
||||
context._ddp_custom_rel_type = rel_type
|
||||
|
||||
|
||||
@when("I try to record a self-loop dependency")
|
||||
def step_try_self_loop_dependency(context: Context) -> None:
|
||||
"""Try to record a self-loop dependency."""
|
||||
try:
|
||||
record_dependency(context, context._ddp_root_id, context._ddp_root_id)
|
||||
context._ddp_self_loop_error = None
|
||||
except DatabaseError as exc:
|
||||
context._ddp_self_loop_error = exc
|
||||
|
||||
|
||||
@when("I try to record the same dependency again")
|
||||
def step_try_duplicate_dependency(context: Context) -> None:
|
||||
"""Try to record the same dependency again."""
|
||||
try:
|
||||
record_dependency(context, context._ddp_root_id, context._ddp_child_id)
|
||||
context._ddp_duplicate_error = None
|
||||
except DatabaseError as exc:
|
||||
context._ddp_duplicate_error = exc
|
||||
|
||||
|
||||
@when("I try to record a cross-plan dependency")
|
||||
def step_try_cross_plan_dependency(context: Context) -> None:
|
||||
"""Try to record a cross-plan dependency."""
|
||||
try:
|
||||
record_dependency(
|
||||
context,
|
||||
context._ddp_cross_plan_id1,
|
||||
context._ddp_cross_plan_id2,
|
||||
)
|
||||
context._ddp_cross_plan_error = None
|
||||
except DatabaseError as exc:
|
||||
context._ddp_cross_plan_error = exc
|
||||
|
||||
|
||||
@when("I get influence edges for the plan")
|
||||
def step_get_influence_edges(context: Context) -> None:
|
||||
"""Get influence edges for the plan."""
|
||||
context._ddp_influence_edges = get_edges(context, PLAN_ID_1)
|
||||
|
||||
|
||||
@when("I get influence edges for plan 1")
|
||||
def step_get_influence_edges_plan1(context: Context) -> None:
|
||||
"""Get influence edges for plan 1."""
|
||||
context._ddp_influence_edges_plan1 = get_edges(context, PLAN_ID_1)
|
||||
|
||||
|
||||
@when("I get influence edges for plan 2")
|
||||
def step_get_influence_edges_plan2(context: Context) -> None:
|
||||
"""Get influence edges for plan 2."""
|
||||
context._ddp_influence_edges_plan2 = get_edges(context, PLAN_ID_2)
|
||||
|
||||
|
||||
@then("the dependency should be persisted to the database")
|
||||
def step_assert_dependency_persisted(context: Context) -> None:
|
||||
"""Assert that the dependency was persisted."""
|
||||
edges = get_edges(context)
|
||||
target_id = getattr(context, "_ddp_new_decision_id", None)
|
||||
if target_id is None:
|
||||
target_id = context._ddp_child_id
|
||||
assert context._ddp_root_id in edges, "Root should be in edges"
|
||||
assert target_id in edges[context._ddp_root_id], "Child should be in root's targets"
|
||||
|
||||
|
||||
@then('the dependency should have relationship_type "{rel_type}"')
|
||||
def step_assert_dependency_rel_type(context: Context, rel_type: str) -> None:
|
||||
"""Assert that the dependency has the correct relationship type."""
|
||||
_assert_dependency_relationship_type(context, rel_type)
|
||||
|
||||
|
||||
@then('the dependency should be persisted with relationship_type "{rel_type}"')
|
||||
def step_assert_dependency_persisted_with_type(context: Context, rel_type: str) -> None:
|
||||
"""Assert that the dependency was persisted with the correct type."""
|
||||
_assert_dependency_relationship_type(context, rel_type)
|
||||
|
||||
|
||||
@then("both dependencies should be persisted to the database")
|
||||
def step_assert_both_dependencies_persisted(context: Context) -> None:
|
||||
"""Assert that both dependencies were persisted."""
|
||||
edges = get_edges(context)
|
||||
if hasattr(context, "_ddp_new_decision_id"):
|
||||
for root_id in context._ddp_root_ids:
|
||||
assert root_id in edges, f"Root {root_id} should be in edges"
|
||||
assert context._ddp_new_decision_id in edges[root_id], (
|
||||
f"New decision should be in {root_id}'s targets"
|
||||
)
|
||||
return
|
||||
|
||||
assert context._ddp_root_id in edges, "Root should be in edges"
|
||||
assert len(edges[context._ddp_root_id]) == 2, "Root should have 2 targets"
|
||||
for sibling_id in context._ddp_sibling_ids:
|
||||
assert sibling_id in edges[context._ddp_root_id], (
|
||||
f"Sibling {sibling_id} should be in root's targets"
|
||||
)
|
||||
|
||||
|
||||
@then("the influence edges should be empty")
|
||||
def step_assert_influence_edges_empty(context: Context) -> None:
|
||||
"""Assert that influence edges are empty."""
|
||||
assert context._ddp_influence_edges == {}, "Influence edges should be empty"
|
||||
|
||||
|
||||
@then("the influence edges should contain {count:d} source")
|
||||
def step_assert_influence_edges_source_count(context: Context, count: int) -> None:
|
||||
"""Assert the number of sources in influence edges."""
|
||||
assert len(context._ddp_influence_edges) == count, (
|
||||
f"Should have {count} source(s), got {len(context._ddp_influence_edges)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the influence edges should contain {count:d} sources")
|
||||
def step_assert_influence_edges_sources_count(context: Context, count: int) -> None:
|
||||
"""Assert the number of sources in influence edges."""
|
||||
step_assert_influence_edges_source_count(context, count)
|
||||
|
||||
|
||||
@then("the influence edges should map root to {targets}")
|
||||
def step_assert_influence_edges_mapping(context: Context, targets: str) -> None:
|
||||
"""Assert that influence edges map root to the specified targets."""
|
||||
if targets == "[child]":
|
||||
expected = [context._ddp_child_id]
|
||||
elif targets == "[sibling1, sibling2]":
|
||||
expected = context._ddp_sibling_ids
|
||||
else:
|
||||
expected = []
|
||||
|
||||
assert context._ddp_root_id in context._ddp_influence_edges, (
|
||||
"Root should be in edges"
|
||||
)
|
||||
actual = context._ddp_influence_edges[context._ddp_root_id]
|
||||
assert set(actual) == set(expected), f"Expected {expected}, got {actual}"
|
||||
|
||||
|
||||
@then("the influence edges should only include decisions from plan 1")
|
||||
def step_assert_influence_edges_plan1_only(context: Context) -> None:
|
||||
"""Assert that influence edges only include plan 1 decisions."""
|
||||
edges = context._ddp_influence_edges_plan1
|
||||
plan1_ids = {
|
||||
context._ddp_plan1_root_id,
|
||||
context._ddp_plan1_child_id,
|
||||
}
|
||||
for source, targets in edges.items():
|
||||
assert source in plan1_ids, f"Source {source} should be in plan 1"
|
||||
for target in targets:
|
||||
assert target in plan1_ids, f"Target {target} should be in plan 1"
|
||||
|
||||
|
||||
@then("the cross-plan dependency should not appear in influence edges")
|
||||
def step_assert_no_cross_plan_dependency(context: Context) -> None:
|
||||
"""Assert that cross-plan dependencies don't appear in edges."""
|
||||
edges = get_edges(context)
|
||||
if context._ddp_cross_plan_id1 in edges:
|
||||
assert context._ddp_cross_plan_id2 not in edges[context._ddp_cross_plan_id1], (
|
||||
"Cross-plan dependency should not appear"
|
||||
)
|
||||
|
||||
|
||||
@then("a database constraint error should be raised")
|
||||
def step_assert_constraint_error(context: Context) -> None:
|
||||
"""Assert that a constraint error was raised."""
|
||||
assert context._ddp_self_loop_error is not None, (
|
||||
"Should have raised a constraint error"
|
||||
)
|
||||
|
||||
|
||||
@then("the operation should complete without error")
|
||||
def step_assert_no_error(context: Context) -> None:
|
||||
"""Assert that the operation completed without error."""
|
||||
assert context._ddp_duplicate_error is None, "Should not have raised an error"
|
||||
|
||||
|
||||
def _assert_dependency_relationship_type(context: Context, rel_type: str) -> None:
|
||||
session = context._ddp_factory()
|
||||
dep = (
|
||||
session.query(DecisionDependencyModel)
|
||||
.filter_by(
|
||||
source_decision_id=context._ddp_root_id,
|
||||
target_decision_id=context._ddp_child_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
assert dep is not None, "Dependency should exist"
|
||||
assert dep.relationship_type == rel_type, (
|
||||
f"Relationship type should be {rel_type}, got {dep.relationship_type}"
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""DecisionService and correction workflow steps for dependency persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.application.services.decision_service import DecisionService
|
||||
from cleveragents.domain.models.core.decision import DecisionType
|
||||
|
||||
from features.steps.helpers.decision_dependency_helpers import (
|
||||
PLAN_ID_1,
|
||||
affected_subtree,
|
||||
create_child,
|
||||
create_three_level_tree,
|
||||
get_edges,
|
||||
record_dependency,
|
||||
record_three_level_edges,
|
||||
)
|
||||
|
||||
|
||||
@given("a persisted decision tree with 3 levels for dependencies")
|
||||
def step_decision_tree_3levels_dep(context: Context) -> None:
|
||||
"""Create a 3-level decision tree."""
|
||||
create_three_level_tree(context)
|
||||
|
||||
|
||||
@given("recorded dependencies forming a DAG")
|
||||
def step_recorded_dag_dep(context: Context) -> None:
|
||||
"""Record dependencies forming a DAG."""
|
||||
record_three_level_edges(context)
|
||||
|
||||
|
||||
@given("a persisted decision tree with dependencies")
|
||||
def step_persisted_decision_tree_with_dependencies(context: Context) -> None:
|
||||
"""Create a persisted decision tree and matching dependency edges."""
|
||||
create_three_level_tree(context)
|
||||
record_three_level_edges(context)
|
||||
context._ddp_root_id = context._ddp_tree_root_id
|
||||
context._ddp_child_id = context._ddp_tree_child_id
|
||||
|
||||
|
||||
@given("a persisted child decision depending on the root")
|
||||
def step_persisted_child_decision_depending_on_root(context: Context) -> None:
|
||||
"""Create a child decision and persist a root -> child dependency."""
|
||||
child = create_child(context, context._ddp_root_id)
|
||||
context._ddp_child_id = child.decision_id
|
||||
context._ddp_child_decision = child
|
||||
record_dependency(context, context._ddp_root_id, context._ddp_child_id)
|
||||
|
||||
|
||||
@when("I create a new DecisionService instance with the same database")
|
||||
def step_new_decision_service(context: Context) -> None:
|
||||
"""Create a new DecisionService with the same database."""
|
||||
context._ddp_new_service = DecisionService(unit_of_work=context._ddp_uow)
|
||||
|
||||
|
||||
@when("I record a new decision with dependency_decision_ids={deps}")
|
||||
def step_record_decision_with_deps(context: Context, deps: str) -> None:
|
||||
"""Record a new decision with dependencies."""
|
||||
service = DecisionService(unit_of_work=context._ddp_uow)
|
||||
|
||||
if deps == "[]":
|
||||
dep_ids = []
|
||||
elif deps == "[root]":
|
||||
dep_ids = [context._ddp_root_id]
|
||||
elif deps == "[root1, root2]":
|
||||
dep_ids = context._ddp_root_ids
|
||||
else:
|
||||
raise AssertionError(f"Unsupported dependency list literal: {deps}")
|
||||
|
||||
decision = service.record_decision(
|
||||
plan_id=PLAN_ID_1,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
question="What strategy?",
|
||||
chosen_option="Strategy A",
|
||||
dependency_decision_ids=dep_ids,
|
||||
)
|
||||
context._ddp_new_decision_id = decision.decision_id
|
||||
context._ddp_new_decision = decision
|
||||
|
||||
|
||||
@when("I compute affected subtree using influence edges")
|
||||
def step_compute_affected_subtree(context: Context) -> None:
|
||||
"""Compute all downstream decisions from the persisted influence DAG."""
|
||||
context._ddp_influence_edges = get_edges(context)
|
||||
context._ddp_affected_subtree = affected_subtree(
|
||||
context._ddp_tree_root_id,
|
||||
context._ddp_influence_edges,
|
||||
)
|
||||
|
||||
|
||||
@when("I record a correction decision with the same dependencies")
|
||||
def step_record_correction_decision_same_dependencies(context: Context) -> None:
|
||||
"""Record a correction decision that depends on the same root."""
|
||||
service = DecisionService(unit_of_work=context._ddp_uow)
|
||||
correction = service.record_decision(
|
||||
plan_id=PLAN_ID_1,
|
||||
decision_type=DecisionType.USER_INTERVENTION,
|
||||
question="How should the previous decision be corrected?",
|
||||
chosen_option="Apply corrected approach",
|
||||
parent_decision_id=context._ddp_child_id,
|
||||
dependency_decision_ids=[context._ddp_root_id],
|
||||
)
|
||||
context._ddp_correction_decision_id = correction.decision_id
|
||||
|
||||
|
||||
@then("the new service should retrieve the same dependency edges")
|
||||
def step_assert_new_service_same_edges(context: Context) -> None:
|
||||
"""Assert that a new service retrieves the same edges."""
|
||||
new_edges = context._ddp_new_service.get_influence_edges(PLAN_ID_1)
|
||||
assert context._ddp_root_id in new_edges, "Root should be in new edges"
|
||||
assert context._ddp_child_id in new_edges[context._ddp_root_id], (
|
||||
"Child should be in new root's targets"
|
||||
)
|
||||
|
||||
|
||||
@then("the new service should retrieve all dependency edges")
|
||||
def step_assert_new_service_all_edges(context: Context) -> None:
|
||||
"""Assert that a new service retrieves all edges."""
|
||||
new_edges = context._ddp_new_service.get_influence_edges(PLAN_ID_1)
|
||||
assert len(new_edges) == 2, f"Should have 2 sources, got {len(new_edges)}"
|
||||
assert context._ddp_tree_root_id in new_edges, "Root should be in edges"
|
||||
assert context._ddp_tree_child_id in new_edges, "Child should be in edges"
|
||||
|
||||
|
||||
@then("no dependencies should be created")
|
||||
def step_assert_no_dependencies_created(context: Context) -> None:
|
||||
"""Assert that no dependencies were created."""
|
||||
edges = get_edges(context)
|
||||
assert edges == {}, "No dependencies should be created"
|
||||
|
||||
|
||||
@then("get_influence_edges should return the dependency")
|
||||
def step_assert_get_influence_edges_returns_dep(context: Context) -> None:
|
||||
"""Assert that get_influence_edges returns the dependency."""
|
||||
edges = get_edges(context)
|
||||
assert context._ddp_root_id in edges, "Root should be in edges"
|
||||
assert context._ddp_new_decision_id in edges[context._ddp_root_id], (
|
||||
"New decision should be in root's targets"
|
||||
)
|
||||
|
||||
|
||||
@then("get_influence_edges should return both dependencies")
|
||||
def step_assert_get_influence_edges_returns_both(context: Context) -> None:
|
||||
"""Assert that get_influence_edges returns both dependencies."""
|
||||
edges = get_edges(context)
|
||||
for root_id in context._ddp_root_ids:
|
||||
assert root_id in edges, f"Root {root_id} should be in edges"
|
||||
assert context._ddp_new_decision_id in edges[root_id], (
|
||||
f"New decision should be in {root_id}'s targets"
|
||||
)
|
||||
|
||||
|
||||
@then("get_influence_edges should be empty")
|
||||
def step_assert_get_influence_edges_empty(context: Context) -> None:
|
||||
"""Assert that get_influence_edges returns empty dict."""
|
||||
edges = get_edges(context)
|
||||
assert edges == {}, "Influence edges should be empty"
|
||||
|
||||
|
||||
@then("the affected subtree should include all transitive dependents")
|
||||
def step_assert_affected_subtree_transitive(context: Context) -> None:
|
||||
"""Assert transitive dependents are included in the affected subtree."""
|
||||
expected = {
|
||||
context._ddp_tree_child_id,
|
||||
context._ddp_tree_grandchild_id,
|
||||
}
|
||||
assert expected <= context._ddp_affected_subtree, (
|
||||
f"Expected {expected}, got {context._ddp_affected_subtree}"
|
||||
)
|
||||
|
||||
|
||||
@then("the correction decision should have the same influence edges")
|
||||
def step_assert_correction_decision_has_same_edges(context: Context) -> None:
|
||||
"""Assert the correction decision is linked to the same upstream root."""
|
||||
edges = get_edges(context)
|
||||
assert context._ddp_root_id in edges, "Root should be in influence edges"
|
||||
assert context._ddp_child_id in edges[context._ddp_root_id], (
|
||||
"Original child dependency should remain"
|
||||
)
|
||||
assert context._ddp_correction_decision_id in edges[context._ddp_root_id], (
|
||||
"Correction decision should depend on the same root"
|
||||
)
|
||||
@@ -1,670 +0,0 @@
|
||||
"""Step definitions for decision_dependency_persistence.feature.
|
||||
|
||||
Tests decision dependency persistence, influence DAG retrieval,
|
||||
and integration with DecisionService.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from cleveragents.application.services.decision_service import DecisionService
|
||||
from cleveragents.core.exceptions import DatabaseError
|
||||
from cleveragents.domain.models.core.action import Action, ActionState
|
||||
from cleveragents.domain.models.core.decision import Decision, DecisionType
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
NamespacedName,
|
||||
Plan,
|
||||
PlanIdentity,
|
||||
PlanPhase,
|
||||
PlanTimestamps,
|
||||
ProcessingState,
|
||||
)
|
||||
from cleveragents.infrastructure.database.models import Base
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
ActionRepository,
|
||||
DecisionRepository,
|
||||
LifecyclePlanRepository,
|
||||
)
|
||||
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PLAN_ID_1 = "01HV000000000000000000DP01"
|
||||
_PLAN_ID_2 = "01HV000000000000000000DP02"
|
||||
|
||||
|
||||
def _setup_db(context: Context) -> None:
|
||||
"""Create an in-memory SQLite DB and attach repos."""
|
||||
engine = create_engine("sqlite:///:memory:", echo=False)
|
||||
Base.metadata.create_all(engine)
|
||||
sm = sessionmaker(bind=engine)
|
||||
session = sm()
|
||||
context._ddp_engine = engine
|
||||
context._ddp_session = session
|
||||
context._ddp_factory = lambda: session
|
||||
context._ddp_repo = DecisionRepository(session_factory=context._ddp_factory)
|
||||
context._ddp_action_repo = ActionRepository(session_factory=context._ddp_factory)
|
||||
context._ddp_plan_repo = LifecyclePlanRepository(
|
||||
session_factory=context._ddp_factory,
|
||||
)
|
||||
context._ddp_uow = UnitOfWork(
|
||||
engine=engine,
|
||||
session_factory=context._ddp_factory,
|
||||
)
|
||||
|
||||
|
||||
def _make_decision(
|
||||
plan_id: str = _PLAN_ID_1,
|
||||
parent_decision_id: str | None = None,
|
||||
sequence_number: int = 0,
|
||||
decision_type: DecisionType = DecisionType.PROMPT_DEFINITION,
|
||||
question: str = "What approach should we take?",
|
||||
chosen_option: str = "Build a REST API",
|
||||
) -> Decision:
|
||||
"""Create a Decision domain model."""
|
||||
return Decision(
|
||||
plan_id=plan_id,
|
||||
sequence_number=sequence_number,
|
||||
decision_type=decision_type,
|
||||
question=question,
|
||||
chosen_option=chosen_option,
|
||||
parent_decision_id=parent_decision_id,
|
||||
)
|
||||
|
||||
|
||||
def _create_prerequisite_action(context: Context, action_name: str) -> None:
|
||||
"""Create a prerequisite action for testing."""
|
||||
ns = NamespacedName.parse(action_name)
|
||||
action = Action(
|
||||
namespaced_name=ns,
|
||||
description="Prerequisite action for dependency tests",
|
||||
definition_of_done="Done",
|
||||
strategy_actor="local/s",
|
||||
execution_actor="local/e",
|
||||
state=ActionState.AVAILABLE,
|
||||
created_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
updated_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
)
|
||||
context._ddp_action_repo.create(action)
|
||||
context._ddp_session.commit()
|
||||
|
||||
|
||||
def _create_prerequisite_plan(context: Context, plan_id: str) -> None:
|
||||
"""Create a prerequisite plan for testing."""
|
||||
now = datetime(2026, 3, 1, tzinfo=UTC)
|
||||
plan = Plan(
|
||||
identity=PlanIdentity(plan_id=plan_id, attempt=1),
|
||||
namespaced_name=NamespacedName(namespace="local", name="decision-plan"),
|
||||
action_name="local/decision-action",
|
||||
description="Decision test plan",
|
||||
definition_of_done="All assertions pass",
|
||||
phase=PlanPhase.STRATEGIZE,
|
||||
processing_state=ProcessingState.PROCESSING,
|
||||
strategy_actor="local/s",
|
||||
execution_actor="local/e",
|
||||
timestamps=PlanTimestamps(created_at=now, updated_at=now),
|
||||
created_by="test-dependency",
|
||||
tags=[],
|
||||
reusable=True,
|
||||
read_only=False,
|
||||
)
|
||||
context._ddp_plan_repo.create(plan)
|
||||
context._ddp_session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a fresh database for decision dependencies")
|
||||
def step_fresh_db(context: Context) -> None:
|
||||
"""Set up a fresh in-memory database."""
|
||||
_setup_db(context)
|
||||
|
||||
|
||||
@given('a prerequisite action "{action_name}" exists for dependencies')
|
||||
def step_prereq_action(context: Context, action_name: str) -> None:
|
||||
"""Create a prerequisite action."""
|
||||
_create_prerequisite_action(context, action_name)
|
||||
|
||||
|
||||
@given("a prerequisite plan exists for dependencies")
|
||||
def step_prereq_plan(context: Context) -> None:
|
||||
"""Create a prerequisite plan."""
|
||||
_create_prerequisite_plan(context, _PLAN_ID_1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decision creation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a persisted root decision for dependencies")
|
||||
def step_persisted_root_dep(context: Context) -> None:
|
||||
"""Create and persist a root decision."""
|
||||
d = _make_decision(plan_id=_PLAN_ID_1, sequence_number=0)
|
||||
context._ddp_repo.create(d)
|
||||
context._ddp_session.commit()
|
||||
context._ddp_root_id = d.decision_id
|
||||
context._ddp_root_decision = d
|
||||
|
||||
|
||||
@given("a persisted child decision for dependencies")
|
||||
def step_persisted_child_dep(context: Context) -> None:
|
||||
"""Create and persist a child decision."""
|
||||
d = _make_decision(
|
||||
plan_id=_PLAN_ID_1,
|
||||
parent_decision_id=context._ddp_root_id,
|
||||
sequence_number=1,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
)
|
||||
context._ddp_repo.create(d)
|
||||
context._ddp_session.commit()
|
||||
context._ddp_child_id = d.decision_id
|
||||
context._ddp_child_decision = d
|
||||
|
||||
|
||||
@given("{count:d} persisted sibling decisions for dependencies")
|
||||
def step_persisted_siblings_dep(context: Context, count: int) -> None:
|
||||
"""Create and persist multiple sibling decisions."""
|
||||
context._ddp_sibling_ids = []
|
||||
for i in range(count):
|
||||
d = _make_decision(
|
||||
plan_id=_PLAN_ID_1,
|
||||
parent_decision_id=context._ddp_root_id,
|
||||
sequence_number=i + 1,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
)
|
||||
context._ddp_repo.create(d)
|
||||
context._ddp_sibling_ids.append(d.decision_id)
|
||||
context._ddp_session.commit()
|
||||
|
||||
|
||||
@given("{count:d} persisted root decisions for dependencies")
|
||||
def step_persisted_roots_dep(context: Context, count: int) -> None:
|
||||
"""Create and persist multiple root decisions."""
|
||||
context._ddp_root_ids = []
|
||||
for i in range(count):
|
||||
d = _make_decision(
|
||||
plan_id=_PLAN_ID_1,
|
||||
sequence_number=i,
|
||||
)
|
||||
context._ddp_repo.create(d)
|
||||
context._ddp_root_ids.append(d.decision_id)
|
||||
context._ddp_session.commit()
|
||||
|
||||
|
||||
@given("{count:d} persisted plans for dependencies")
|
||||
def step_persisted_plans_dep(context: Context, count: int) -> None:
|
||||
"""Create and persist multiple plans."""
|
||||
context._ddp_plan_ids = [_PLAN_ID_1]
|
||||
if count > 1:
|
||||
_create_prerequisite_plan(context, _PLAN_ID_2)
|
||||
context._ddp_plan_ids.append(_PLAN_ID_2)
|
||||
|
||||
|
||||
@given("a decision tree in plan 1 with dependencies")
|
||||
def step_decision_tree_plan1_dep(context: Context) -> None:
|
||||
"""Create a decision tree in plan 1 with dependencies."""
|
||||
# Create root
|
||||
root = _make_decision(plan_id=_PLAN_ID_1, sequence_number=0)
|
||||
context._ddp_repo.create(root)
|
||||
context._ddp_session.commit()
|
||||
root_id = root.decision_id
|
||||
|
||||
# Create child
|
||||
child = _make_decision(
|
||||
plan_id=_PLAN_ID_1,
|
||||
parent_decision_id=root_id,
|
||||
sequence_number=1,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
)
|
||||
context._ddp_repo.create(child)
|
||||
context._ddp_session.commit()
|
||||
|
||||
# Record dependency
|
||||
context._ddp_repo.record_dependency(root_id, child.decision_id)
|
||||
context._ddp_session.commit()
|
||||
|
||||
context._ddp_plan1_root_id = root_id
|
||||
context._ddp_plan1_child_id = child.decision_id
|
||||
|
||||
|
||||
@given("a decision tree in plan 2 with dependencies")
|
||||
def step_decision_tree_plan2_dep(context: Context) -> None:
|
||||
"""Create a decision tree in plan 2 with dependencies."""
|
||||
# Create root
|
||||
root = _make_decision(plan_id=_PLAN_ID_2, sequence_number=0)
|
||||
context._ddp_repo.create(root)
|
||||
context._ddp_session.commit()
|
||||
root_id = root.decision_id
|
||||
|
||||
# Create child
|
||||
child = _make_decision(
|
||||
plan_id=_PLAN_ID_2,
|
||||
parent_decision_id=root_id,
|
||||
sequence_number=1,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
)
|
||||
context._ddp_repo.create(child)
|
||||
context._ddp_session.commit()
|
||||
|
||||
# Record dependency
|
||||
context._ddp_repo.record_dependency(root_id, child.decision_id)
|
||||
context._ddp_session.commit()
|
||||
|
||||
context._ddp_plan2_root_id = root_id
|
||||
context._ddp_plan2_child_id = child.decision_id
|
||||
|
||||
|
||||
@given("a decision in plan 1 and a decision in plan 2")
|
||||
def step_cross_plan_decisions_dep(context: Context) -> None:
|
||||
"""Create decisions in different plans."""
|
||||
d1 = _make_decision(plan_id=_PLAN_ID_1, sequence_number=0)
|
||||
context._ddp_repo.create(d1)
|
||||
|
||||
d2 = _make_decision(plan_id=_PLAN_ID_2, sequence_number=0)
|
||||
context._ddp_repo.create(d2)
|
||||
context._ddp_session.commit()
|
||||
|
||||
context._ddp_cross_plan_id1 = d1.decision_id
|
||||
context._ddp_cross_plan_id2 = d2.decision_id
|
||||
|
||||
|
||||
@given("a persisted decision tree with 3 levels for dependencies")
|
||||
def step_decision_tree_3levels_dep(context: Context) -> None:
|
||||
"""Create a 3-level decision tree."""
|
||||
# Level 0: root
|
||||
root = _make_decision(plan_id=_PLAN_ID_1, sequence_number=0)
|
||||
context._ddp_repo.create(root)
|
||||
context._ddp_session.commit()
|
||||
root_id = root.decision_id
|
||||
|
||||
# Level 1: child
|
||||
child = _make_decision(
|
||||
plan_id=_PLAN_ID_1,
|
||||
parent_decision_id=root_id,
|
||||
sequence_number=1,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
)
|
||||
context._ddp_repo.create(child)
|
||||
context._ddp_session.commit()
|
||||
child_id = child.decision_id
|
||||
|
||||
# Level 2: grandchild
|
||||
grandchild = _make_decision(
|
||||
plan_id=_PLAN_ID_1,
|
||||
parent_decision_id=child_id,
|
||||
sequence_number=2,
|
||||
decision_type=DecisionType.IMPLEMENTATION_CHOICE,
|
||||
)
|
||||
context._ddp_repo.create(grandchild)
|
||||
context._ddp_session.commit()
|
||||
grandchild_id = grandchild.decision_id
|
||||
|
||||
context._ddp_tree_root_id = root_id
|
||||
context._ddp_tree_child_id = child_id
|
||||
context._ddp_tree_grandchild_id = grandchild_id
|
||||
|
||||
|
||||
@given("recorded dependencies forming a DAG")
|
||||
def step_recorded_dag_dep(context: Context) -> None:
|
||||
"""Record dependencies forming a DAG."""
|
||||
context._ddp_repo.record_dependency(
|
||||
context._ddp_tree_root_id,
|
||||
context._ddp_tree_child_id,
|
||||
)
|
||||
context._ddp_repo.record_dependency(
|
||||
context._ddp_tree_child_id,
|
||||
context._ddp_tree_grandchild_id,
|
||||
)
|
||||
context._ddp_session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dependency recording
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I record a dependency from the root to the child")
|
||||
def step_record_dependency_root_child(context: Context) -> None:
|
||||
"""Record a dependency from root to child."""
|
||||
context._ddp_repo.record_dependency(
|
||||
context._ddp_root_id,
|
||||
context._ddp_child_id,
|
||||
)
|
||||
context._ddp_session.commit()
|
||||
|
||||
|
||||
@when("I record dependencies from the root to both siblings")
|
||||
def step_record_dependencies_root_siblings(context: Context) -> None:
|
||||
"""Record dependencies from root to all siblings."""
|
||||
for sibling_id in context._ddp_sibling_ids:
|
||||
context._ddp_repo.record_dependency(
|
||||
context._ddp_root_id,
|
||||
sibling_id,
|
||||
)
|
||||
context._ddp_session.commit()
|
||||
|
||||
|
||||
@when("I record a dependency with relationship_type {rel_type}")
|
||||
def step_record_dependency_custom_type(context: Context, rel_type: str) -> None:
|
||||
"""Record a dependency with custom relationship type."""
|
||||
context._ddp_repo.record_dependency(
|
||||
context._ddp_root_id,
|
||||
context._ddp_child_id,
|
||||
relationship_type=rel_type,
|
||||
)
|
||||
context._ddp_session.commit()
|
||||
context._ddp_custom_rel_type = rel_type
|
||||
|
||||
|
||||
@when("I try to record a self-loop dependency")
|
||||
def step_try_self_loop_dependency(context: Context) -> None:
|
||||
"""Try to record a self-loop dependency."""
|
||||
try:
|
||||
context._ddp_repo.record_dependency(
|
||||
context._ddp_root_id,
|
||||
context._ddp_root_id,
|
||||
)
|
||||
context._ddp_session.commit()
|
||||
context._ddp_self_loop_error = None
|
||||
except DatabaseError as e:
|
||||
context._ddp_self_loop_error = e
|
||||
|
||||
|
||||
@when("I try to record the same dependency again")
|
||||
def step_try_duplicate_dependency(context: Context) -> None:
|
||||
"""Try to record the same dependency again."""
|
||||
try:
|
||||
context._ddp_repo.record_dependency(
|
||||
context._ddp_root_id,
|
||||
context._ddp_child_id,
|
||||
)
|
||||
context._ddp_session.commit()
|
||||
context._ddp_duplicate_error = None
|
||||
except DatabaseError as e:
|
||||
context._ddp_duplicate_error = e
|
||||
|
||||
|
||||
@when("I try to record a cross-plan dependency")
|
||||
def step_try_cross_plan_dependency(context: Context) -> None:
|
||||
"""Try to record a cross-plan dependency."""
|
||||
try:
|
||||
context._ddp_repo.record_dependency(
|
||||
context._ddp_cross_plan_id1,
|
||||
context._ddp_cross_plan_id2,
|
||||
)
|
||||
context._ddp_session.commit()
|
||||
context._ddp_cross_plan_error = None
|
||||
except DatabaseError as e:
|
||||
context._ddp_cross_plan_error = e
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Influence edges retrieval
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I get influence edges for the plan")
|
||||
def step_get_influence_edges(context: Context) -> None:
|
||||
"""Get influence edges for the plan."""
|
||||
context._ddp_influence_edges = context._ddp_repo.get_influence_edges(_PLAN_ID_1)
|
||||
|
||||
|
||||
@when("I get influence edges for plan 1")
|
||||
def step_get_influence_edges_plan1(context: Context) -> None:
|
||||
"""Get influence edges for plan 1."""
|
||||
context._ddp_influence_edges_plan1 = context._ddp_repo.get_influence_edges(
|
||||
_PLAN_ID_1
|
||||
)
|
||||
|
||||
|
||||
@when("I get influence edges for plan 2")
|
||||
def step_get_influence_edges_plan2(context: Context) -> None:
|
||||
"""Get influence edges for plan 2."""
|
||||
context._ddp_influence_edges_plan2 = context._ddp_repo.get_influence_edges(
|
||||
_PLAN_ID_2
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DecisionService integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I create a new DecisionService instance with the same database")
|
||||
def step_new_decision_service(context: Context) -> None:
|
||||
"""Create a new DecisionService with the same database."""
|
||||
context._ddp_new_service = DecisionService(
|
||||
unit_of_work=context._ddp_uow,
|
||||
)
|
||||
|
||||
|
||||
@when("I record a new decision with dependency_decision_ids={deps}")
|
||||
def step_record_decision_with_deps(context: Context, deps: str) -> None:
|
||||
"""Record a new decision with dependencies."""
|
||||
# Parse the deps string (e.g., "[root]" or "[root1, root2]")
|
||||
service = DecisionService(unit_of_work=context._ddp_uow)
|
||||
|
||||
if deps == "[]":
|
||||
dep_ids = []
|
||||
elif deps == "[root]":
|
||||
dep_ids = [context._ddp_root_id]
|
||||
elif deps == "[root1, root2]":
|
||||
dep_ids = context._ddp_root_ids
|
||||
else:
|
||||
dep_ids = []
|
||||
|
||||
decision = service.record_decision(
|
||||
plan_id=_PLAN_ID_1,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
question="What strategy?",
|
||||
chosen_option="Strategy A",
|
||||
dependency_decision_ids=dep_ids,
|
||||
)
|
||||
context._ddp_new_decision_id = decision.decision_id
|
||||
context._ddp_new_decision = decision
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the dependency should be persisted to the database")
|
||||
def step_assert_dependency_persisted(context: Context) -> None:
|
||||
"""Assert that the dependency was persisted."""
|
||||
edges = context._ddp_repo.get_influence_edges(_PLAN_ID_1)
|
||||
assert context._ddp_root_id in edges, "Root should be in edges"
|
||||
assert context._ddp_child_id in edges[context._ddp_root_id], (
|
||||
"Child should be in root's targets"
|
||||
)
|
||||
|
||||
|
||||
@then("the dependency should have relationship_type {rel_type}")
|
||||
def step_assert_dependency_rel_type(context: Context, rel_type: str) -> None:
|
||||
"""Assert that the dependency has the correct relationship type."""
|
||||
# Note: The current implementation doesn't return relationship_type,
|
||||
# but we can verify it was stored by checking the database directly
|
||||
from cleveragents.infrastructure.database.models import DecisionDependencyModel
|
||||
|
||||
session = context._ddp_factory()
|
||||
dep = (
|
||||
session.query(DecisionDependencyModel)
|
||||
.filter_by(
|
||||
source_decision_id=context._ddp_root_id,
|
||||
target_decision_id=context._ddp_child_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
assert dep is not None, "Dependency should exist"
|
||||
assert dep.relationship_type == rel_type, (
|
||||
f"Relationship type should be {rel_type}, got {dep.relationship_type}"
|
||||
)
|
||||
|
||||
|
||||
@then("the dependency should be persisted with relationship_type {rel_type}")
|
||||
def step_assert_dependency_persisted_with_type(context: Context, rel_type: str) -> None:
|
||||
"""Assert that the dependency was persisted with the correct type."""
|
||||
from cleveragents.infrastructure.database.models import DecisionDependencyModel
|
||||
|
||||
session = context._ddp_factory()
|
||||
dep = (
|
||||
session.query(DecisionDependencyModel)
|
||||
.filter_by(
|
||||
source_decision_id=context._ddp_root_id,
|
||||
target_decision_id=context._ddp_child_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
assert dep is not None, "Dependency should exist"
|
||||
assert dep.relationship_type == rel_type, (
|
||||
f"Relationship type should be {rel_type}, got {dep.relationship_type}"
|
||||
)
|
||||
|
||||
|
||||
@then("both dependencies should be persisted to the database")
|
||||
def step_assert_both_dependencies_persisted(context: Context) -> None:
|
||||
"""Assert that both dependencies were persisted."""
|
||||
edges = context._ddp_repo.get_influence_edges(_PLAN_ID_1)
|
||||
assert context._ddp_root_id in edges, "Root should be in edges"
|
||||
assert len(edges[context._ddp_root_id]) == 2, "Root should have 2 targets"
|
||||
for sibling_id in context._ddp_sibling_ids:
|
||||
assert sibling_id in edges[context._ddp_root_id], (
|
||||
f"Sibling {sibling_id} should be in root's targets"
|
||||
)
|
||||
|
||||
|
||||
@then("the influence edges should be empty")
|
||||
def step_assert_influence_edges_empty(context: Context) -> None:
|
||||
"""Assert that influence edges are empty."""
|
||||
assert context._ddp_influence_edges == {}, "Influence edges should be empty"
|
||||
|
||||
|
||||
@then("the influence edges should contain {count:d} source")
|
||||
def step_assert_influence_edges_source_count(context: Context, count: int) -> None:
|
||||
"""Assert the number of sources in influence edges."""
|
||||
assert len(context._ddp_influence_edges) == count, (
|
||||
f"Should have {count} source(s), got {len(context._ddp_influence_edges)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the influence edges should map root to {targets}")
|
||||
def step_assert_influence_edges_mapping(context: Context, targets: str) -> None:
|
||||
"""Assert that influence edges map root to the specified targets."""
|
||||
# Parse targets string (e.g., "[child]" or "[sibling1, sibling2]")
|
||||
if targets == "[child]":
|
||||
expected = [context._ddp_child_id]
|
||||
elif targets == "[sibling1, sibling2]":
|
||||
expected = context._ddp_sibling_ids
|
||||
else:
|
||||
expected = []
|
||||
|
||||
assert context._ddp_root_id in context._ddp_influence_edges, (
|
||||
"Root should be in edges"
|
||||
)
|
||||
actual = context._ddp_influence_edges[context._ddp_root_id]
|
||||
assert set(actual) == set(expected), f"Expected {expected}, got {actual}"
|
||||
|
||||
|
||||
@then("the influence edges should only include decisions from plan 1")
|
||||
def step_assert_influence_edges_plan1_only(context: Context) -> None:
|
||||
"""Assert that influence edges only include plan 1 decisions."""
|
||||
edges = context._ddp_influence_edges_plan1
|
||||
plan1_ids = {
|
||||
context._ddp_plan1_root_id,
|
||||
context._ddp_plan1_child_id,
|
||||
}
|
||||
for source, targets in edges.items():
|
||||
assert source in plan1_ids, f"Source {source} should be in plan 1"
|
||||
for target in targets:
|
||||
assert target in plan1_ids, f"Target {target} should be in plan 1"
|
||||
|
||||
|
||||
@then("the cross-plan dependency should not appear in influence edges")
|
||||
def step_assert_no_cross_plan_dependency(context: Context) -> None:
|
||||
"""Assert that cross-plan dependencies don't appear in edges."""
|
||||
edges = context._ddp_repo.get_influence_edges(_PLAN_ID_1)
|
||||
# The cross-plan dependency should not appear because the target is in a different plan
|
||||
if context._ddp_cross_plan_id1 in edges:
|
||||
assert context._ddp_cross_plan_id2 not in edges[context._ddp_cross_plan_id1], (
|
||||
"Cross-plan dependency should not appear"
|
||||
)
|
||||
|
||||
|
||||
@then("the new service should retrieve the same dependency edges")
|
||||
def step_assert_new_service_same_edges(context: Context) -> None:
|
||||
"""Assert that a new service retrieves the same edges."""
|
||||
new_edges = context._ddp_new_service.get_influence_edges(_PLAN_ID_1)
|
||||
assert context._ddp_root_id in new_edges, "Root should be in new edges"
|
||||
assert context._ddp_child_id in new_edges[context._ddp_root_id], (
|
||||
"Child should be in new root's targets"
|
||||
)
|
||||
|
||||
|
||||
@then("the new service should retrieve all dependency edges")
|
||||
def step_assert_new_service_all_edges(context: Context) -> None:
|
||||
"""Assert that a new service retrieves all edges."""
|
||||
new_edges = context._ddp_new_service.get_influence_edges(_PLAN_ID_1)
|
||||
assert len(new_edges) == 2, f"Should have 2 sources, got {len(new_edges)}"
|
||||
assert context._ddp_tree_root_id in new_edges, "Root should be in edges"
|
||||
assert context._ddp_tree_child_id in new_edges, "Child should be in edges"
|
||||
|
||||
|
||||
@then("a database constraint error should be raised")
|
||||
def step_assert_constraint_error(context: Context) -> None:
|
||||
"""Assert that a constraint error was raised."""
|
||||
assert context._ddp_self_loop_error is not None, (
|
||||
"Should have raised a constraint error"
|
||||
)
|
||||
|
||||
|
||||
@then("the operation should complete without error")
|
||||
def step_assert_no_error(context: Context) -> None:
|
||||
"""Assert that the operation completed without error."""
|
||||
assert context._ddp_duplicate_error is None, "Should not have raised an error"
|
||||
|
||||
|
||||
@then("no dependencies should be created")
|
||||
def step_assert_no_dependencies_created(context: Context) -> None:
|
||||
"""Assert that no dependencies were created."""
|
||||
edges = context._ddp_repo.get_influence_edges(_PLAN_ID_1)
|
||||
assert edges == {}, "No dependencies should be created"
|
||||
|
||||
|
||||
@then("get_influence_edges should return the dependency")
|
||||
def step_assert_get_influence_edges_returns_dep(context: Context) -> None:
|
||||
"""Assert that get_influence_edges returns the dependency."""
|
||||
edges = context._ddp_repo.get_influence_edges(_PLAN_ID_1)
|
||||
assert context._ddp_root_id in edges, "Root should be in edges"
|
||||
assert context._ddp_new_decision_id in edges[context._ddp_root_id], (
|
||||
"New decision should be in root's targets"
|
||||
)
|
||||
|
||||
|
||||
@then("get_influence_edges should return both dependencies")
|
||||
def step_assert_get_influence_edges_returns_both(context: Context) -> None:
|
||||
"""Assert that get_influence_edges returns both dependencies."""
|
||||
edges = context._ddp_repo.get_influence_edges(_PLAN_ID_1)
|
||||
for root_id in context._ddp_root_ids:
|
||||
assert root_id in edges, f"Root {root_id} should be in edges"
|
||||
assert context._ddp_new_decision_id in edges[root_id], (
|
||||
f"New decision should be in {root_id}'s targets"
|
||||
)
|
||||
|
||||
|
||||
@then("get_influence_edges should be empty")
|
||||
def step_assert_get_influence_edges_empty(context: Context) -> None:
|
||||
"""Assert that get_influence_edges returns empty dict."""
|
||||
edges = context._ddp_repo.get_influence_edges(_PLAN_ID_1)
|
||||
assert edges == {}, "Influence edges should be empty"
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared Behave step helpers."""
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Shared helpers for decision dependency persistence Behave steps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from collections import deque
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from behave.runner import Context
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from cleveragents.domain.models.core.action import Action, ActionState
|
||||
from cleveragents.domain.models.core.decision import Decision, DecisionType
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
NamespacedName,
|
||||
Plan,
|
||||
PlanIdentity,
|
||||
PlanPhase,
|
||||
PlanTimestamps,
|
||||
ProcessingState,
|
||||
)
|
||||
from cleveragents.infrastructure.database.models import Base
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
ActionRepository,
|
||||
DecisionRepository,
|
||||
LifecyclePlanRepository,
|
||||
)
|
||||
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
|
||||
|
||||
PLAN_ID_1 = "01HV000000000000000000DP01"
|
||||
PLAN_ID_2 = "01HV000000000000000000DP02"
|
||||
|
||||
|
||||
def setup_db(context: Context) -> None:
|
||||
"""Create a per-scenario SQLite database and attach repositories."""
|
||||
tmp_dir = Path(tempfile.mkdtemp(prefix="ddp-", dir="/tmp"))
|
||||
db_path = tmp_dir / "decision-dependencies.sqlite"
|
||||
engine = create_engine(f"sqlite:///{db_path}", echo=False)
|
||||
Base.metadata.create_all(engine)
|
||||
session_factory = sessionmaker(
|
||||
bind=engine,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
)
|
||||
session = session_factory()
|
||||
|
||||
context._ddp_tmp_dir = tmp_dir
|
||||
context._ddp_db_url = f"sqlite:///{db_path}"
|
||||
context._ddp_engine = engine
|
||||
context._ddp_session = session
|
||||
context._ddp_uow_session_factory = session_factory
|
||||
context._ddp_factory = lambda: session
|
||||
context._ddp_repo = DecisionRepository(session_factory=context._ddp_factory)
|
||||
context._ddp_action_repo = ActionRepository(session_factory=context._ddp_factory)
|
||||
context._ddp_plan_repo = LifecyclePlanRepository(
|
||||
session_factory=context._ddp_factory,
|
||||
)
|
||||
context._ddp_uow = UnitOfWork(
|
||||
context._ddp_db_url,
|
||||
require_confirmation=False,
|
||||
)
|
||||
context._ddp_uow._engine = engine
|
||||
context._ddp_uow._session_factory = session_factory
|
||||
|
||||
|
||||
def make_decision(
|
||||
plan_id: str = PLAN_ID_1,
|
||||
parent_decision_id: str | None = None,
|
||||
sequence_number: int = 0,
|
||||
decision_type: DecisionType = DecisionType.PROMPT_DEFINITION,
|
||||
question: str = "What approach should we take?",
|
||||
chosen_option: str = "Build a REST API",
|
||||
) -> Decision:
|
||||
"""Create a Decision domain model."""
|
||||
return Decision(
|
||||
plan_id=plan_id,
|
||||
sequence_number=sequence_number,
|
||||
decision_type=decision_type,
|
||||
question=question,
|
||||
chosen_option=chosen_option,
|
||||
parent_decision_id=parent_decision_id,
|
||||
)
|
||||
|
||||
|
||||
def create_prerequisite_action(context: Context, action_name: str) -> None:
|
||||
"""Create a prerequisite action for testing."""
|
||||
ns = NamespacedName.parse(action_name)
|
||||
action = Action(
|
||||
namespaced_name=ns,
|
||||
description="Prerequisite action for dependency tests",
|
||||
definition_of_done="Done",
|
||||
strategy_actor="local/s",
|
||||
execution_actor="local/e",
|
||||
state=ActionState.AVAILABLE,
|
||||
created_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
updated_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
)
|
||||
context._ddp_action_repo.create(action)
|
||||
context._ddp_session.commit()
|
||||
|
||||
|
||||
def create_prerequisite_plan(context: Context, plan_id: str) -> None:
|
||||
"""Create a prerequisite plan for testing."""
|
||||
now = datetime(2026, 3, 1, tzinfo=UTC)
|
||||
plan = Plan(
|
||||
identity=PlanIdentity(plan_id=plan_id, attempt=1),
|
||||
namespaced_name=NamespacedName(namespace="local", name="decision-plan"),
|
||||
action_name="local/decision-action",
|
||||
description="Decision test plan",
|
||||
definition_of_done="All assertions pass",
|
||||
phase=PlanPhase.STRATEGIZE,
|
||||
processing_state=ProcessingState.PROCESSING,
|
||||
strategy_actor="local/s",
|
||||
execution_actor="local/e",
|
||||
timestamps=PlanTimestamps(created_at=now, updated_at=now),
|
||||
created_by="test-dependency",
|
||||
tags=[],
|
||||
reusable=True,
|
||||
read_only=False,
|
||||
)
|
||||
context._ddp_plan_repo.create(plan)
|
||||
context._ddp_session.commit()
|
||||
|
||||
|
||||
def persist_decision(context: Context, decision: Decision) -> Decision:
|
||||
"""Persist one decision and commit it."""
|
||||
context._ddp_repo.create(decision)
|
||||
context._ddp_session.commit()
|
||||
return decision
|
||||
|
||||
|
||||
def create_root(context: Context, plan_id: str = PLAN_ID_1, index: int = 0) -> Decision:
|
||||
"""Create a persisted root decision."""
|
||||
return persist_decision(
|
||||
context,
|
||||
make_decision(plan_id=plan_id, sequence_number=index),
|
||||
)
|
||||
|
||||
|
||||
def create_child(
|
||||
context: Context,
|
||||
root_id: str,
|
||||
plan_id: str = PLAN_ID_1,
|
||||
sequence_number: int = 1,
|
||||
) -> Decision:
|
||||
"""Create a persisted child decision."""
|
||||
return persist_decision(
|
||||
context,
|
||||
make_decision(
|
||||
plan_id=plan_id,
|
||||
parent_decision_id=root_id,
|
||||
sequence_number=sequence_number,
|
||||
decision_type=DecisionType.STRATEGY_CHOICE,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def record_dependency(
|
||||
context: Context,
|
||||
source_id: str,
|
||||
target_id: str,
|
||||
relationship_type: str = "influences",
|
||||
) -> None:
|
||||
"""Persist one dependency edge and commit it."""
|
||||
context._ddp_repo.record_dependency(
|
||||
source_id,
|
||||
target_id,
|
||||
relationship_type=relationship_type,
|
||||
)
|
||||
context._ddp_session.commit()
|
||||
|
||||
|
||||
def get_edges(context: Context, plan_id: str = PLAN_ID_1) -> dict[str, list[str]]:
|
||||
"""Read influence edges after expiring the assertion session cache."""
|
||||
context._ddp_session.expire_all()
|
||||
return context._ddp_repo.get_influence_edges(plan_id)
|
||||
|
||||
|
||||
def create_three_level_tree(context: Context) -> None:
|
||||
"""Create a root -> child -> grandchild tree."""
|
||||
root = create_root(context)
|
||||
child = create_child(context, root.decision_id, sequence_number=1)
|
||||
grandchild = persist_decision(
|
||||
context,
|
||||
make_decision(
|
||||
plan_id=PLAN_ID_1,
|
||||
parent_decision_id=child.decision_id,
|
||||
sequence_number=2,
|
||||
decision_type=DecisionType.IMPLEMENTATION_CHOICE,
|
||||
),
|
||||
)
|
||||
context._ddp_tree_root_id = root.decision_id
|
||||
context._ddp_tree_child_id = child.decision_id
|
||||
context._ddp_tree_grandchild_id = grandchild.decision_id
|
||||
|
||||
|
||||
def record_three_level_edges(context: Context) -> None:
|
||||
"""Record root -> child and child -> grandchild dependencies."""
|
||||
record_dependency(
|
||||
context,
|
||||
context._ddp_tree_root_id,
|
||||
context._ddp_tree_child_id,
|
||||
)
|
||||
record_dependency(
|
||||
context,
|
||||
context._ddp_tree_child_id,
|
||||
context._ddp_tree_grandchild_id,
|
||||
)
|
||||
|
||||
|
||||
def affected_subtree(start_id: str, edges: dict[str, list[str]]) -> set[str]:
|
||||
"""Return all transitive dependents reachable from start_id."""
|
||||
affected: set[str] = set()
|
||||
queue: deque[str] = deque(edges.get(start_id, []))
|
||||
while queue:
|
||||
decision_id = queue.popleft()
|
||||
if decision_id in affected:
|
||||
continue
|
||||
affected.add(decision_id)
|
||||
queue.extend(edges.get(decision_id, []))
|
||||
return affected
|
||||
@@ -111,6 +111,7 @@ from cleveragents.infrastructure.database.models import (
|
||||
ContextModel,
|
||||
CorrectionAttemptModel,
|
||||
DebugAttemptModel,
|
||||
DecisionDependencyModel,
|
||||
DecisionModel,
|
||||
LifecycleActionModel,
|
||||
LifecyclePlanModel,
|
||||
@@ -5754,10 +5755,8 @@ class DecisionRepository(DecisionRepositoryProtocol):
|
||||
Raises:
|
||||
DatabaseError: On constraint violation or transient DB errors.
|
||||
"""
|
||||
from cleveragents.infrastructure.database.models import (
|
||||
DecisionDependencyModel,
|
||||
)
|
||||
|
||||
if source_decision_id == target_decision_id:
|
||||
raise DatabaseError("Decision dependency self-loops are not allowed")
|
||||
session = self._session()
|
||||
try:
|
||||
now_iso = datetime.now(UTC).isoformat()
|
||||
@@ -5795,11 +5794,6 @@ class DecisionRepository(DecisionRepositoryProtocol):
|
||||
Raises:
|
||||
DatabaseError: On transient or unexpected DB errors.
|
||||
"""
|
||||
from cleveragents.infrastructure.database.models import (
|
||||
DecisionDependencyModel,
|
||||
DecisionModel,
|
||||
)
|
||||
|
||||
session = self._session()
|
||||
try:
|
||||
# Get all decision IDs for the plan
|
||||
|
||||
Reference in New Issue
Block a user