86b0c62e69
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
240 lines
7.9 KiB
Python
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"
|