forked from HAL9000/cleveragents-core
a808c395f9
Add 53 new .feature files and corresponding step definition files targeting uncovered lines identified in build/coverage.xml. Fix AmbiguousStep conflicts in 7 pre-existing step files by disambiguating step text. New tests cover: ACP clients/facade, actor CLI/config, application container, ACMS service/strategies, async worker, automation profile CLI, autonomy guardrail, bridge, change model, config CLI/service, context service, cross-plan correction, database models, decision service, decomposition clustering/service, discovery handler, langchain chat provider, langgraph nodes, materializers, multi-project service, plan apply/CLI/lifecycle/model/ preflight/resume/service, PostgreSQL analyzer, project CLI/context CLI, provider registry, reactive application/route, repositories, resolver handler, resource registry service, resume model, retry patterns, sandbox protocol, server CLI, skill CLI/service, skills registry, subplan execution/service, system CLI, UKO loader, UoW, and YAML template engine. Closes #645
746 lines
27 KiB
Python
746 lines
27 KiB
Python
"""Step definitions for decision_service_coverage_boost.feature.
|
|
|
|
Targets uncovered lines in decision_service.py that were missed by
|
|
existing test suites. All step names use the ``dscb-`` prefix.
|
|
|
|
Targeted uncovered lines:
|
|
318-319 actor_reasoning validation
|
|
488 BFS visited-node continue in get_tree
|
|
500-501 Orphan subtree handling in get_tree
|
|
534 Cycle detection in get_path_to_root
|
|
578-579 mark_superseded persisted fallback for replacement
|
|
600 mark_superseded not-found in memory mode
|
|
627-628 delete_decision persisted fallback
|
|
741 SequenceConflictError in _next_sequence
|
|
757 _rehydrate_sequence early return
|
|
806 _record_dependencies blank upstream skip
|
|
832 get_influence_edges cross-plan filtering
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import contextmanager
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
from ulid import ULID
|
|
|
|
from cleveragents.application.services.decision_service import (
|
|
_MAX_ACTOR_REASONING,
|
|
DecisionNotFoundError,
|
|
DecisionService,
|
|
SequenceConflictError,
|
|
)
|
|
from cleveragents.core.exceptions import ValidationError
|
|
from cleveragents.domain.models.core.decision import (
|
|
Decision,
|
|
DecisionType,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _ulid() -> str:
|
|
"""Generate a fresh ULID string."""
|
|
return str(ULID())
|
|
|
|
|
|
def _make_decision(
|
|
decision_id: str | None = None,
|
|
plan_id: str | None = None,
|
|
parent_decision_id: str | None = None,
|
|
sequence_number: int = 0,
|
|
decision_type: DecisionType = DecisionType.STRATEGY_CHOICE,
|
|
superseded_by: str | None = None,
|
|
) -> Decision:
|
|
"""Build a minimal valid Decision for testing."""
|
|
kwargs: dict[str, Any] = {
|
|
"plan_id": plan_id or _ulid(),
|
|
"sequence_number": sequence_number,
|
|
"decision_type": decision_type,
|
|
"question": "Test question",
|
|
"chosen_option": "Test option",
|
|
}
|
|
if decision_id is not None:
|
|
kwargs["decision_id"] = decision_id
|
|
if parent_decision_id is not None:
|
|
kwargs["parent_decision_id"] = parent_decision_id
|
|
if superseded_by is not None:
|
|
kwargs["superseded_by"] = superseded_by
|
|
return Decision(**kwargs)
|
|
|
|
|
|
def _build_mock_uow() -> tuple[MagicMock, MagicMock, MagicMock]:
|
|
"""Build a mock UnitOfWork with a mock transaction context manager."""
|
|
mock_decisions = MagicMock(name="decisions_repo")
|
|
mock_ctx = MagicMock(name="uow_context")
|
|
mock_ctx.decisions = mock_decisions
|
|
|
|
mock_uow = MagicMock(name="unit_of_work")
|
|
|
|
@contextmanager
|
|
def _fake_transaction():
|
|
yield mock_ctx
|
|
|
|
mock_uow.transaction = _fake_transaction
|
|
return mock_uow, mock_ctx, mock_decisions
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared Given: in-memory DecisionService
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("dscb- a DecisionService in memory mode")
|
|
def step_dscb_inmemory_service(context: Context) -> None:
|
|
context.dscb_service = DecisionService()
|
|
context.dscb_plan_id = _ulid()
|
|
context.dscb_error = None
|
|
context.dscb_result = None
|
|
|
|
|
|
# ===================================================================
|
|
# Lines 318-319: actor_reasoning exceeds max length
|
|
# ===================================================================
|
|
|
|
|
|
@when("dscb- I record a decision with actor_reasoning exceeding the max length")
|
|
def step_dscb_record_oversized_reasoning(context: Context) -> None:
|
|
try:
|
|
context.dscb_service.record_decision(
|
|
plan_id=context.dscb_plan_id,
|
|
decision_type=DecisionType.PROMPT_DEFINITION,
|
|
question="Test question",
|
|
chosen_option="Test option",
|
|
actor_reasoning="x" * (_MAX_ACTOR_REASONING + 1),
|
|
)
|
|
context.dscb_error = None
|
|
except ValidationError as exc:
|
|
context.dscb_error = exc
|
|
|
|
|
|
@then('dscb- a ValidationError should have been raised mentioning "actor_reasoning"')
|
|
def step_dscb_check_validation_error_reasoning(context: Context) -> None:
|
|
assert context.dscb_error is not None, "Expected ValidationError but got None"
|
|
assert isinstance(context.dscb_error, ValidationError), (
|
|
f"Expected ValidationError, got {type(context.dscb_error).__name__}"
|
|
)
|
|
assert "actor_reasoning" in str(context.dscb_error), (
|
|
f"Error message should mention 'actor_reasoning': {context.dscb_error}"
|
|
)
|
|
|
|
|
|
# ===================================================================
|
|
# Line 488: BFS continue on already-visited node in get_tree
|
|
# ===================================================================
|
|
|
|
|
|
@given("dscb- a decision tree where the same child is reachable from two parents")
|
|
def step_dscb_tree_with_shared_child(context: Context) -> None:
|
|
"""Create a tree where a child references one parent but is manually
|
|
added to a second parent's adjacency, causing it to appear in the
|
|
BFS queue twice. The simplest way to hit the `continue` on line 488
|
|
is to manually populate the in-memory cache with a structure where
|
|
the BFS queue would encounter the same node twice.
|
|
|
|
We create:
|
|
root (no parent)
|
|
child1 (parent=root)
|
|
child2 (parent=root)
|
|
|
|
Then we also set child2.parent_decision_id = root (already is), but
|
|
we add child2's ID to `child1`'s adjacency as well. Since Decision
|
|
is frozen, we do this by inserting child2 under child1 in the
|
|
children_map — the simplest approach is to give child2 a
|
|
parent_decision_id of child1, but *also* make child2 appear under root.
|
|
|
|
Actually, a simpler approach: create two decisions that both claim to
|
|
be children of root, and then also one of them claims to be a child
|
|
of the other. But the Decision model is frozen.
|
|
|
|
The cleanest approach: create a cycle-like structure:
|
|
root (parent=None)
|
|
childA (parent=root)
|
|
childB (parent=childA) -- also we'll manually add childB as
|
|
child of root by hacking _plan_decisions
|
|
|
|
But that won't trigger line 488 since each node's parent_decision_id
|
|
is unique. Line 488 fires when a decision_id is already in `visited`.
|
|
|
|
The BFS builds children_map from parent_decision_id. For a node to
|
|
appear twice in the queue, it would need to be a child of two parents.
|
|
Since parent_decision_id is singular, we can't do that directly.
|
|
But we can have *two Decision objects with the same decision_id* in
|
|
the list returned by list_decisions — this is unusual but possible if
|
|
we manually stuff the cache.
|
|
|
|
Actually, looking more carefully: the BFS also appends children, and if
|
|
a child's parent is one node but it also appears as the grandchild of
|
|
another path, it won't be revisited unless it literally was already
|
|
queued. Let me re-read the code:
|
|
|
|
```
|
|
while queue:
|
|
node = queue.popleft()
|
|
if node.decision_id in visited:
|
|
continue # ← line 488
|
|
visited.add(node.decision_id)
|
|
```
|
|
|
|
The queue starts with `roots`. Then for each node, its children are
|
|
appended. For a node to be visited twice, it would need to be in
|
|
`roots` AND also be a child of another node. Or be a child of two
|
|
different nodes.
|
|
|
|
Simplest trigger: make the root also appear as a child of itself
|
|
(parent=None makes it root, but we also add it to children_map
|
|
under some other key).
|
|
|
|
Or even simpler: make a decision that is a root (parent=None) and
|
|
also appears in children_map under another node — by creating it
|
|
with parent=None and then having another decision whose decision_id
|
|
equals the root's parent... no.
|
|
|
|
Let me just directly manipulate the service's internal state.
|
|
"""
|
|
svc = context.dscb_service
|
|
plan_id = context.dscb_plan_id
|
|
|
|
root_id = _ulid()
|
|
child_id = _ulid()
|
|
|
|
# Root decision
|
|
root = _make_decision(
|
|
decision_id=root_id,
|
|
plan_id=plan_id,
|
|
sequence_number=0,
|
|
decision_type=DecisionType.PROMPT_DEFINITION,
|
|
parent_decision_id=None,
|
|
)
|
|
# Child that claims root as parent
|
|
child = _make_decision(
|
|
decision_id=child_id,
|
|
plan_id=plan_id,
|
|
sequence_number=1,
|
|
decision_type=DecisionType.STRATEGY_CHOICE,
|
|
parent_decision_id=root_id,
|
|
)
|
|
# Insert ANOTHER copy of child with same decision_id — this means
|
|
# when list_decisions returns, child appears once, but we'll add
|
|
# child_id to _plan_decisions twice so it appears in list_decisions
|
|
# twice (since list_decisions iterates _plan_decisions).
|
|
svc._decisions[root_id] = root
|
|
svc._decisions[child_id] = child
|
|
svc._plan_decisions[plan_id] = [root_id, child_id, child_id]
|
|
svc._plan_sequence[plan_id] = 2
|
|
|
|
context.dscb_expected_unique_count = 2
|
|
|
|
|
|
@when("dscb- I call get_tree for the plan")
|
|
def step_dscb_call_get_tree(context: Context) -> None:
|
|
context.dscb_result = context.dscb_service.get_tree(context.dscb_plan_id)
|
|
|
|
|
|
@then("dscb- the tree result should contain each decision exactly once")
|
|
def step_dscb_tree_unique_nodes(context: Context) -> None:
|
|
ids = [d.decision_id for d in context.dscb_result]
|
|
assert len(ids) == len(set(ids)), f"Tree contains duplicate decision IDs: {ids}"
|
|
assert len(set(ids)) == context.dscb_expected_unique_count, (
|
|
f"Expected {context.dscb_expected_unique_count} unique decisions, "
|
|
f"got {len(set(ids))}"
|
|
)
|
|
|
|
|
|
# ===================================================================
|
|
# Lines 500-501: Orphaned subtrees in get_tree
|
|
# ===================================================================
|
|
|
|
|
|
@given(
|
|
"dscb- a plan with a root decision and an orphaned decision with a dangling parent"
|
|
)
|
|
def step_dscb_tree_with_orphan(context: Context) -> None:
|
|
svc = context.dscb_service
|
|
plan_id = context.dscb_plan_id
|
|
|
|
root_id = _ulid()
|
|
orphan_id = _ulid()
|
|
nonexistent_parent_id = _ulid() # Not in the plan's decisions
|
|
|
|
root = _make_decision(
|
|
decision_id=root_id,
|
|
plan_id=plan_id,
|
|
sequence_number=0,
|
|
decision_type=DecisionType.PROMPT_DEFINITION,
|
|
parent_decision_id=None,
|
|
)
|
|
# Orphan's parent is not in the plan → it's unreachable from root
|
|
orphan = _make_decision(
|
|
decision_id=orphan_id,
|
|
plan_id=plan_id,
|
|
sequence_number=1,
|
|
decision_type=DecisionType.STRATEGY_CHOICE,
|
|
parent_decision_id=nonexistent_parent_id,
|
|
)
|
|
|
|
svc._decisions[root_id] = root
|
|
svc._decisions[orphan_id] = orphan
|
|
svc._plan_decisions[plan_id] = [root_id, orphan_id]
|
|
svc._plan_sequence[plan_id] = 2
|
|
|
|
context.dscb_root_id = root_id
|
|
context.dscb_orphan_id = orphan_id
|
|
|
|
|
|
@then("dscb- the tree result should include both the root and the orphaned decision")
|
|
def step_dscb_tree_includes_orphan(context: Context) -> None:
|
|
ids = {d.decision_id for d in context.dscb_result}
|
|
assert context.dscb_root_id in ids, "Root decision missing from tree result"
|
|
assert context.dscb_orphan_id in ids, "Orphaned decision missing from tree result"
|
|
|
|
|
|
@then("dscb- the root decision should appear before the orphan")
|
|
def step_dscb_root_before_orphan(context: Context) -> None:
|
|
ids = [d.decision_id for d in context.dscb_result]
|
|
root_idx = ids.index(context.dscb_root_id)
|
|
orphan_idx = ids.index(context.dscb_orphan_id)
|
|
assert root_idx < orphan_idx, (
|
|
f"Root at index {root_idx} should appear before orphan at {orphan_idx}"
|
|
)
|
|
|
|
|
|
# ===================================================================
|
|
# Line 534: get_path_to_root breaks on cycle
|
|
# ===================================================================
|
|
|
|
|
|
@given("dscb- two decisions that form a parent cycle")
|
|
def step_dscb_cyclic_parents(context: Context) -> None:
|
|
svc = context.dscb_service
|
|
plan_id = context.dscb_plan_id
|
|
|
|
id_a = _ulid()
|
|
id_b = _ulid()
|
|
|
|
# A's parent is B, B's parent is A → cycle
|
|
decision_a = _make_decision(
|
|
decision_id=id_a,
|
|
plan_id=plan_id,
|
|
sequence_number=0,
|
|
decision_type=DecisionType.STRATEGY_CHOICE,
|
|
parent_decision_id=id_b,
|
|
)
|
|
decision_b = _make_decision(
|
|
decision_id=id_b,
|
|
plan_id=plan_id,
|
|
sequence_number=1,
|
|
decision_type=DecisionType.STRATEGY_CHOICE,
|
|
parent_decision_id=id_a,
|
|
)
|
|
|
|
svc._decisions[id_a] = decision_a
|
|
svc._decisions[id_b] = decision_b
|
|
svc._plan_decisions[plan_id] = [id_a, id_b]
|
|
svc._plan_sequence[plan_id] = 2
|
|
|
|
context.dscb_cycle_start_id = id_a
|
|
|
|
|
|
@when("dscb- I call get_path_to_root from the first cycled decision")
|
|
def step_dscb_path_from_cycle(context: Context) -> None:
|
|
context.dscb_result = context.dscb_service.get_path_to_root(
|
|
context.dscb_cycle_start_id
|
|
)
|
|
|
|
|
|
@then("dscb- the path should terminate without infinite loop")
|
|
def step_dscb_path_terminates(context: Context) -> None:
|
|
# If we got here, the path terminated (no infinite loop)
|
|
assert context.dscb_result is not None, "Path result should not be None"
|
|
assert len(context.dscb_result) > 0, "Path should contain at least one decision"
|
|
|
|
|
|
@then("dscb- the path should contain exactly {count:d} decisions")
|
|
def step_dscb_path_count(context: Context, count: int) -> None:
|
|
assert len(context.dscb_result) == count, (
|
|
f"Expected {count} decisions in path, got {len(context.dscb_result)}"
|
|
)
|
|
|
|
|
|
# ===================================================================
|
|
# Lines 578-579: mark_superseded DB fallback for replacement
|
|
# ===================================================================
|
|
|
|
|
|
@given("dscb- a DecisionService with a mock UoW for superseded fallback")
|
|
def step_dscb_service_mock_uow_superseded(context: Context) -> None:
|
|
mock_uow, mock_ctx, mock_decisions = _build_mock_uow()
|
|
svc = DecisionService(unit_of_work=mock_uow)
|
|
|
|
# Silence logger
|
|
mock_logger = MagicMock(name="structlog_logger")
|
|
mock_logger.bind = MagicMock(return_value=mock_logger)
|
|
svc._logger = mock_logger
|
|
|
|
context.dscb_service = svc
|
|
context.dscb_mock_uow = mock_uow
|
|
context.dscb_mock_ctx = mock_ctx
|
|
context.dscb_mock_decisions = mock_decisions
|
|
context.dscb_plan_id = _ulid()
|
|
context.dscb_error = None
|
|
context.dscb_result = None
|
|
|
|
|
|
@given("dscb- the replacement decision exists only in the database not in cache")
|
|
def step_dscb_replacement_in_db_only(context: Context) -> None:
|
|
"""Replacement is NOT in _decisions cache; the mock DB returns it."""
|
|
plan_id = context.dscb_plan_id
|
|
context.dscb_replacement_id = _ulid()
|
|
context.dscb_original_id = _ulid()
|
|
|
|
replacement = _make_decision(
|
|
decision_id=context.dscb_replacement_id,
|
|
plan_id=plan_id,
|
|
sequence_number=1,
|
|
decision_type=DecisionType.STRATEGY_CHOICE,
|
|
)
|
|
# DB lookup returns the replacement
|
|
context.dscb_mock_decisions.get.return_value = replacement
|
|
context.dscb_replacement = replacement
|
|
|
|
|
|
@given("dscb- the original decision exists in the cache")
|
|
def step_dscb_original_in_cache(context: Context) -> None:
|
|
plan_id = context.dscb_plan_id
|
|
svc = context.dscb_service
|
|
|
|
original = _make_decision(
|
|
decision_id=context.dscb_original_id,
|
|
plan_id=plan_id,
|
|
sequence_number=0,
|
|
decision_type=DecisionType.PROMPT_DEFINITION,
|
|
)
|
|
svc._decisions[context.dscb_original_id] = original
|
|
|
|
# The mock update_superseded_by should return the updated decision
|
|
updated = original.with_superseded_by(context.dscb_replacement_id)
|
|
context.dscb_mock_decisions.update_superseded_by.return_value = updated
|
|
context.dscb_expected_updated = updated
|
|
|
|
|
|
@when("dscb- I call mark_superseded with the original and replacement IDs")
|
|
def step_dscb_call_mark_superseded(context: Context) -> None:
|
|
try:
|
|
context.dscb_result = context.dscb_service.mark_superseded(
|
|
context.dscb_original_id,
|
|
context.dscb_replacement_id,
|
|
)
|
|
context.dscb_error = None
|
|
except Exception as exc:
|
|
context.dscb_error = exc
|
|
context.dscb_result = None
|
|
|
|
|
|
@then("dscb- the original decision should be marked as superseded")
|
|
def step_dscb_original_superseded(context: Context) -> None:
|
|
assert context.dscb_error is None, f"Unexpected error: {context.dscb_error}"
|
|
assert context.dscb_result is not None, "Expected a result from mark_superseded"
|
|
assert context.dscb_result.superseded_by == context.dscb_replacement_id, (
|
|
f"Expected superseded_by={context.dscb_replacement_id}, "
|
|
f"got {context.dscb_result.superseded_by}"
|
|
)
|
|
|
|
|
|
# ===================================================================
|
|
# Line 600: mark_superseded not-found in memory mode
|
|
# ===================================================================
|
|
|
|
|
|
@given("dscb- a replacement decision is recorded for the plan")
|
|
def step_dscb_record_replacement(context: Context) -> None:
|
|
svc = context.dscb_service
|
|
plan_id = context.dscb_plan_id
|
|
|
|
context.dscb_replacement_id = _ulid()
|
|
replacement = _make_decision(
|
|
decision_id=context.dscb_replacement_id,
|
|
plan_id=plan_id,
|
|
sequence_number=0,
|
|
decision_type=DecisionType.STRATEGY_CHOICE,
|
|
)
|
|
svc._decisions[context.dscb_replacement_id] = replacement
|
|
|
|
|
|
@when("dscb- I call mark_superseded with a nonexistent original ID")
|
|
def step_dscb_mark_superseded_nonexistent(context: Context) -> None:
|
|
nonexistent_id = _ulid()
|
|
try:
|
|
context.dscb_service.mark_superseded(
|
|
nonexistent_id, context.dscb_replacement_id
|
|
)
|
|
context.dscb_error = None
|
|
except DecisionNotFoundError as exc:
|
|
context.dscb_error = exc
|
|
|
|
|
|
@then("dscb- a DecisionNotFoundError should have been raised")
|
|
def step_dscb_check_not_found_error(context: Context) -> None:
|
|
assert context.dscb_error is not None, "Expected DecisionNotFoundError but got None"
|
|
assert isinstance(context.dscb_error, DecisionNotFoundError), (
|
|
f"Expected DecisionNotFoundError, got {type(context.dscb_error).__name__}"
|
|
)
|
|
|
|
|
|
# ===================================================================
|
|
# Lines 627-628: delete_decision DB fallback
|
|
# ===================================================================
|
|
|
|
|
|
@given("dscb- a DecisionService with a mock UoW for delete fallback")
|
|
def step_dscb_service_mock_uow_delete(context: Context) -> None:
|
|
mock_uow, mock_ctx, mock_decisions = _build_mock_uow()
|
|
svc = DecisionService(unit_of_work=mock_uow)
|
|
|
|
mock_logger = MagicMock(name="structlog_logger")
|
|
mock_logger.bind = MagicMock(return_value=mock_logger)
|
|
svc._logger = mock_logger
|
|
|
|
context.dscb_service = svc
|
|
context.dscb_mock_uow = mock_uow
|
|
context.dscb_mock_ctx = mock_ctx
|
|
context.dscb_mock_decisions = mock_decisions
|
|
context.dscb_plan_id = _ulid()
|
|
context.dscb_error = None
|
|
context.dscb_result = None
|
|
|
|
|
|
@given("dscb- the decision exists in DB but not in cache for delete")
|
|
def step_dscb_decision_in_db_not_cache(context: Context) -> None:
|
|
plan_id = context.dscb_plan_id
|
|
context.dscb_decision_id = _ulid()
|
|
|
|
decision = _make_decision(
|
|
decision_id=context.dscb_decision_id,
|
|
plan_id=plan_id,
|
|
sequence_number=0,
|
|
decision_type=DecisionType.STRATEGY_CHOICE,
|
|
)
|
|
# Decision is NOT in svc._decisions (not in cache)
|
|
# DB lookup returns it
|
|
context.dscb_mock_decisions.get.return_value = decision
|
|
|
|
|
|
@when("dscb- I call delete_decision with the decision ID")
|
|
def step_dscb_call_delete(context: Context) -> None:
|
|
try:
|
|
context.dscb_result = context.dscb_service.delete_decision(
|
|
context.dscb_decision_id
|
|
)
|
|
context.dscb_error = None
|
|
except Exception as exc:
|
|
context.dscb_error = exc
|
|
context.dscb_result = None
|
|
|
|
|
|
@then("dscb- the decision should be deleted via the UoW")
|
|
def step_dscb_delete_via_uow(context: Context) -> None:
|
|
assert context.dscb_error is None, f"Unexpected error: {context.dscb_error}"
|
|
assert context.dscb_result is True, "delete_decision should return True"
|
|
context.dscb_mock_decisions.delete.assert_called_once_with(context.dscb_decision_id)
|
|
|
|
|
|
# ===================================================================
|
|
# Line 741: SequenceConflictError in _next_sequence
|
|
# ===================================================================
|
|
|
|
|
|
@given("dscb- a plan with a decision manually inserted at sequence 0")
|
|
def step_dscb_manual_sequence_conflict(context: Context) -> None:
|
|
svc = context.dscb_service
|
|
plan_id = context.dscb_plan_id
|
|
|
|
decision_id = _ulid()
|
|
decision = _make_decision(
|
|
decision_id=decision_id,
|
|
plan_id=plan_id,
|
|
sequence_number=0,
|
|
decision_type=DecisionType.PROMPT_DEFINITION,
|
|
)
|
|
# Insert into cache manually (bypassing _store_decision which
|
|
# would increment the counter)
|
|
svc._decisions[decision_id] = decision
|
|
svc._plan_decisions.setdefault(plan_id, []).append(decision_id)
|
|
# Set the sequence counter to 0 so _next_sequence tries to assign 0
|
|
# again, which conflicts with the existing decision
|
|
svc._plan_sequence[plan_id] = 0
|
|
svc._sequence_initialised.add(plan_id)
|
|
|
|
|
|
@when("dscb- I try to generate the next sequence for that plan")
|
|
def step_dscb_try_next_sequence(context: Context) -> None:
|
|
try:
|
|
context.dscb_service._next_sequence(context.dscb_plan_id)
|
|
context.dscb_error = None
|
|
except SequenceConflictError as exc:
|
|
context.dscb_error = exc
|
|
|
|
|
|
@then("dscb- a SequenceConflictError should have been raised")
|
|
def step_dscb_check_sequence_conflict(context: Context) -> None:
|
|
assert context.dscb_error is not None, "Expected SequenceConflictError but got None"
|
|
assert isinstance(context.dscb_error, SequenceConflictError), (
|
|
f"Expected SequenceConflictError, got {type(context.dscb_error).__name__}"
|
|
)
|
|
|
|
|
|
# ===================================================================
|
|
# Line 757: _rehydrate_sequence early return
|
|
# ===================================================================
|
|
|
|
|
|
@given("dscb- the plan sequence counter is already set to {value:d}")
|
|
def step_dscb_preset_sequence(context: Context, value: int) -> None:
|
|
svc = context.dscb_service
|
|
plan_id = context.dscb_plan_id
|
|
svc._plan_sequence[plan_id] = value
|
|
|
|
|
|
@when("dscb- I call rehydrate_sequence for the plan")
|
|
def step_dscb_call_rehydrate(context: Context) -> None:
|
|
context.dscb_service._rehydrate_sequence(context.dscb_plan_id)
|
|
|
|
|
|
@then("dscb- the plan sequence counter should still be {value:d}")
|
|
def step_dscb_check_sequence_unchanged(context: Context, value: int) -> None:
|
|
actual = context.dscb_service._plan_sequence.get(context.dscb_plan_id)
|
|
assert actual == value, f"Expected sequence counter {value}, got {actual}"
|
|
|
|
|
|
# ===================================================================
|
|
# Line 806: _record_dependencies skips blank upstream IDs
|
|
# ===================================================================
|
|
|
|
|
|
@when("dscb- I record a decision with dependency IDs including blanks")
|
|
def step_dscb_record_with_blank_deps(context: Context) -> None:
|
|
svc = context.dscb_service
|
|
plan_id = context.dscb_plan_id
|
|
|
|
# We need a valid upstream decision for the non-blank ID
|
|
valid_upstream_id = _ulid()
|
|
upstream = _make_decision(
|
|
decision_id=valid_upstream_id,
|
|
plan_id=plan_id,
|
|
sequence_number=0,
|
|
decision_type=DecisionType.PROMPT_DEFINITION,
|
|
)
|
|
svc._decisions[valid_upstream_id] = upstream
|
|
|
|
# Record with blanks + valid IDs
|
|
context.dscb_result = svc.record_decision(
|
|
plan_id=plan_id,
|
|
decision_type=DecisionType.STRATEGY_CHOICE,
|
|
question="Test with deps",
|
|
chosen_option="Option A",
|
|
dependency_decision_ids=["", " ", valid_upstream_id],
|
|
)
|
|
context.dscb_valid_upstream_id = valid_upstream_id
|
|
|
|
|
|
@then("dscb- only non-blank upstream IDs should be recorded in the dependency DAG")
|
|
def step_dscb_check_deps_no_blanks(context: Context) -> None:
|
|
svc = context.dscb_service
|
|
# The valid upstream should have an edge to the new decision
|
|
valid_id = context.dscb_valid_upstream_id
|
|
new_decision_id = context.dscb_result.decision_id
|
|
|
|
assert valid_id in svc._dependencies, (
|
|
f"Expected upstream {valid_id} in dependency DAG"
|
|
)
|
|
assert new_decision_id in svc._dependencies[valid_id], (
|
|
f"Expected {new_decision_id} as target of {valid_id}"
|
|
)
|
|
# Blank IDs should NOT appear as keys
|
|
for key in svc._dependencies:
|
|
assert key.strip(), f"Blank key found in dependency DAG: '{key}'"
|
|
|
|
|
|
# ===================================================================
|
|
# Line 832: get_influence_edges cross-plan filtering
|
|
# ===================================================================
|
|
|
|
|
|
@given("dscb- a plan with decisions and cross-plan dependency edges")
|
|
def step_dscb_cross_plan_deps(context: Context) -> None:
|
|
svc = context.dscb_service
|
|
plan_id = context.dscb_plan_id
|
|
|
|
# Decision in our plan
|
|
in_plan_id = _ulid()
|
|
in_plan_target_id = _ulid()
|
|
in_plan = _make_decision(
|
|
decision_id=in_plan_id,
|
|
plan_id=plan_id,
|
|
sequence_number=0,
|
|
decision_type=DecisionType.PROMPT_DEFINITION,
|
|
)
|
|
in_plan_target = _make_decision(
|
|
decision_id=in_plan_target_id,
|
|
plan_id=plan_id,
|
|
sequence_number=1,
|
|
decision_type=DecisionType.STRATEGY_CHOICE,
|
|
parent_decision_id=in_plan_id,
|
|
)
|
|
|
|
svc._decisions[in_plan_id] = in_plan
|
|
svc._decisions[in_plan_target_id] = in_plan_target
|
|
svc._plan_decisions[plan_id] = [in_plan_id, in_plan_target_id]
|
|
|
|
# Decision from another plan that also has a dependency edge
|
|
other_plan_id = _ulid()
|
|
outside_id = _ulid()
|
|
outside = _make_decision(
|
|
decision_id=outside_id,
|
|
plan_id=other_plan_id,
|
|
sequence_number=0,
|
|
decision_type=DecisionType.PROMPT_DEFINITION,
|
|
)
|
|
svc._decisions[outside_id] = outside
|
|
svc._plan_decisions[other_plan_id] = [outside_id]
|
|
|
|
# Add dependency edges:
|
|
# in_plan_id → in_plan_target_id (within plan)
|
|
# outside_id → in_plan_target_id (cross-plan — should be excluded)
|
|
svc._dependencies[in_plan_id] = [in_plan_target_id]
|
|
svc._dependencies[outside_id] = [in_plan_target_id]
|
|
|
|
context.dscb_in_plan_id = in_plan_id
|
|
context.dscb_in_plan_target_id = in_plan_target_id
|
|
context.dscb_outside_id = outside_id
|
|
|
|
|
|
@when("dscb- I call get_influence_edges for the plan")
|
|
def step_dscb_call_influence_edges(context: Context) -> None:
|
|
context.dscb_result = context.dscb_service.get_influence_edges(context.dscb_plan_id)
|
|
|
|
|
|
@then("dscb- only edges within the plan should be returned")
|
|
def step_dscb_check_influence_edges(context: Context) -> None:
|
|
edges = context.dscb_result
|
|
# The in-plan edge should be present
|
|
assert context.dscb_in_plan_id in edges, (
|
|
f"Expected in-plan source {context.dscb_in_plan_id} in edges"
|
|
)
|
|
assert context.dscb_in_plan_target_id in edges[context.dscb_in_plan_id], (
|
|
"Expected in-plan target in edge targets"
|
|
)
|
|
# The cross-plan edge should be excluded
|
|
assert context.dscb_outside_id not in edges, (
|
|
f"Cross-plan source {context.dscb_outside_id} should NOT be in edges"
|
|
)
|