Files
cleveragents-core/features/test_plan_tree_service.py
HAL9000 86b0c62e69 feat(plan): implement agents plan tree decision tree rendering
Implements the plan tree decision tree rendering feature for issue #9280.

This commit adds:
- PlanTreeService: Service layer for retrieving and rendering decision trees
- DecisionTreeNode: Data structure for representing tree nodes
- Multiple renderers: Rich (colored), plain text (ASCII), and JSON formats
- Support for --format and --depth options
- Comprehensive unit tests for service and renderers
- BDD feature tests for CLI integration

The implementation supports:
- Hierarchical tree rendering of all decisions in a plan
- Status indicators (pending, completed, reverted)
- Depth limiting to control output size
- Multiple output formats for different use cases
- Error handling for non-existent plans

All quality gates passing:
- Lint: ✓
- Typecheck: ✓
- Unit tests: Pending (long-running test suite)

ISSUES CLOSED: #9280
2026-04-15 16:12:42 +00:00

240 lines
7.9 KiB
Python

"""Unit tests for PlanTreeService."""
from __future__ import annotations
from datetime import datetime
from unittest.mock import MagicMock
import pytest
from cleveragents.application.services.plan_tree_service import (
DecisionTreeNode,
PlanTreeService,
)
from cleveragents.domain.models.core.decision import Decision, DecisionType
@pytest.fixture
def mock_plan_repository() -> MagicMock:
"""Create a mock plan repository."""
return MagicMock()
@pytest.fixture
def mock_decision_repository() -> MagicMock:
"""Create a mock decision repository."""
return MagicMock()
@pytest.fixture
def plan_tree_service(
mock_plan_repository: MagicMock,
mock_decision_repository: MagicMock,
) -> PlanTreeService:
"""Create a PlanTreeService instance."""
return PlanTreeService(mock_plan_repository, mock_decision_repository)
@pytest.fixture
def sample_decisions() -> list[Decision]:
"""Create sample decisions for testing."""
now = datetime.now(datetime.UTC)
decisions = [
Decision(
decision_id="d-001",
plan_id="plan-001",
parent_decision_id=None,
sequence_number=0,
decision_type=DecisionType.PROMPT_DEFINITION,
question="What is the task?",
chosen_option="Analyze code",
rationale="User requested code analysis",
created_at=now,
),
Decision(
decision_id="d-002",
plan_id="plan-001",
parent_decision_id="d-001",
sequence_number=1,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Which strategy?",
chosen_option="Iterative",
rationale="Iterative approach is best",
created_at=now,
),
Decision(
decision_id="d-003",
plan_id="plan-001",
parent_decision_id="d-002",
sequence_number=2,
decision_type=DecisionType.TOOL_SELECTION,
question="Which tool?",
chosen_option="ruff",
rationale="ruff is fast",
created_at=now,
),
Decision(
decision_id="d-004",
plan_id="plan-001",
parent_decision_id="d-001",
sequence_number=3,
decision_type=DecisionType.BRANCH,
question="Await confirmation?",
chosen_option="yes",
rationale="Need user input",
created_at=now,
),
]
# Add status attribute for testing
for decision in decisions:
decision.status = "completed"
return decisions
class TestPlanTreeService:
"""Tests for PlanTreeService."""
def test_get_decision_tree_returns_none_for_nonexistent_plan(
self,
plan_tree_service: PlanTreeService,
mock_plan_repository: MagicMock,
) -> None:
"""Test that get_decision_tree returns None for non-existent plan."""
mock_plan_repository.get_by_id.return_value = None
result = plan_tree_service.get_decision_tree("plan-nonexistent")
assert result is None
mock_plan_repository.get_by_id.assert_called_once_with(
"plan-nonexistent"
)
def test_get_decision_tree_returns_none_for_plan_with_no_decisions(
self,
plan_tree_service: PlanTreeService,
mock_plan_repository: MagicMock,
mock_decision_repository: MagicMock,
) -> None:
"""Test that get_decision_tree returns None for plan with no decisions."""
mock_plan = MagicMock()
mock_plan_repository.get_by_id.return_value = mock_plan
mock_decision_repository.find_by_plan_id.return_value = []
result = plan_tree_service.get_decision_tree("plan-001")
assert result is None
def test_get_decision_tree_builds_correct_tree_structure(
self,
plan_tree_service: PlanTreeService,
mock_plan_repository: MagicMock,
mock_decision_repository: MagicMock,
sample_decisions: list[Decision],
) -> None:
"""Test that get_decision_tree builds correct tree structure."""
mock_plan = MagicMock()
mock_plan_repository.get_by_id.return_value = mock_plan
mock_decision_repository.find_by_plan_id.return_value = sample_decisions
root = plan_tree_service.get_decision_tree("plan-001")
assert root is not None
assert root.decision.decision_id == "d-001"
assert len(root.children) == 2 # d-002 and d-004
assert root.children[0].decision.decision_id == "d-002"
assert root.children[1].decision.decision_id == "d-004"
assert len(root.children[0].children) == 1 # d-003
assert root.children[0].children[0].decision.decision_id == "d-003"
def test_get_decision_tree_respects_depth_limit(
self,
plan_tree_service: PlanTreeService,
mock_plan_repository: MagicMock,
mock_decision_repository: MagicMock,
sample_decisions: list[Decision],
) -> None:
"""Test that get_decision_tree respects depth limit."""
mock_plan = MagicMock()
mock_plan_repository.get_by_id.return_value = mock_plan
mock_decision_repository.find_by_plan_id.return_value = sample_decisions
# Depth 1: only root and immediate children
root = plan_tree_service.get_decision_tree("plan-001", depth=1)
assert root is not None
assert len(root.children) == 2
assert len(root.children[0].children) == 0 # d-003 should not be included
assert len(root.children[1].children) == 0
def test_get_tree_summary_returns_correct_statistics(
self,
plan_tree_service: PlanTreeService,
mock_decision_repository: MagicMock,
sample_decisions: list[Decision],
) -> None:
"""Test that get_tree_summary returns correct statistics."""
mock_decision_repository.find_by_plan_id.return_value = sample_decisions
summary = plan_tree_service.get_tree_summary("plan-001")
assert summary["plan_id"] == "plan-001"
assert summary["total_decisions"] == 4
assert summary["max_depth"] == 2
assert summary["status_counts"]["completed"] == 4
def test_get_tree_summary_for_empty_plan(
self,
plan_tree_service: PlanTreeService,
mock_decision_repository: MagicMock,
) -> None:
"""Test that get_tree_summary handles empty plan."""
mock_decision_repository.find_by_plan_id.return_value = []
summary = plan_tree_service.get_tree_summary("plan-empty")
assert summary["plan_id"] == "plan-empty"
assert summary["total_decisions"] == 0
assert summary["max_depth"] == 0
class TestDecisionTreeNode:
"""Tests for DecisionTreeNode."""
def test_to_dict_includes_all_fields(
self, sample_decisions: list[Decision]
) -> None:
"""Test that to_dict includes all required fields."""
decision = sample_decisions[0]
node = DecisionTreeNode(decision=decision, depth=0)
result = node.to_dict()
assert result["decision_id"] == "d-001"
assert result["plan_id"] == "plan-001"
assert result["type"] == "prompt_definition"
assert "timestamp" in result
assert result["question"] == "What is the task?"
assert result["chosen_option"] == "Analyze code"
assert result["status"] == "completed"
assert result["summary"] == "User requested code analysis"
assert result["children"] == []
def test_to_dict_includes_children(
self, sample_decisions: list[Decision]
) -> None:
"""Test that to_dict includes children."""
parent = sample_decisions[0]
child = sample_decisions[1]
parent_node = DecisionTreeNode(decision=parent, depth=0)
child_node = DecisionTreeNode(decision=child, depth=1)
parent_node.children.append(child_node)
result = parent_node.to_dict()
assert len(result["children"]) == 1
assert result["children"][0]["decision_id"] == "d-002"