Files
cleveragents-core/features/test_plan_tree_renderers.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

261 lines
8.4 KiB
Python

"""Unit tests for plan tree renderers."""
from __future__ import annotations
from datetime import datetime
import pytest
from cleveragents.application.services.plan_tree_service import DecisionTreeNode
from cleveragents.cli.output.plan_tree_renderers import (
JsonPlanTreeRenderer,
PlainPlanTreeRenderer,
RichPlanTreeRenderer,
get_renderer,
)
from cleveragents.domain.models.core.decision import Decision, DecisionType
@pytest.fixture
def sample_decision_tree() -> DecisionTreeNode:
"""Create a sample decision tree for testing."""
now = datetime.now(datetime.UTC)
root_decision = 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 analysis",
created_at=now,
)
root_decision.status = "completed"
child_decision = 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="Best approach",
created_at=now,
)
child_decision.status = "completed"
root_node = DecisionTreeNode(decision=root_decision, depth=0)
child_node = DecisionTreeNode(decision=child_decision, depth=1)
root_node.children.append(child_node)
return root_node
class TestRichPlanTreeRenderer:
"""Tests for RichPlanTreeRenderer."""
def test_render_includes_plan_id(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render includes plan ID."""
renderer = RichPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
assert "Plan Decision Tree: plan-001" in output
def test_render_includes_decision_ids(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render includes decision IDs."""
renderer = RichPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
assert "d-001" in output
assert "d-002" in output
def test_render_includes_decision_types(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render includes decision types."""
renderer = RichPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
assert "prompt_definition" in output
assert "strategy_choice" in output
def test_render_includes_status(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render includes status."""
renderer = RichPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
assert "[completed]" in output
def test_render_includes_timestamp(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render includes timestamp."""
renderer = RichPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
# Check for ISO format timestamp
assert "T" in output # ISO format includes T
def test_render_includes_summary(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render includes summary."""
renderer = RichPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
assert "User requested analysis" in output or "Analyze code" in output
class TestPlainPlanTreeRenderer:
"""Tests for PlainPlanTreeRenderer."""
def test_render_includes_plan_id(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render includes plan ID."""
renderer = PlainPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
assert "Plan Decision Tree: plan-001" in output
def test_render_uses_ascii_connectors(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render uses ASCII connectors."""
renderer = PlainPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
# Should contain ASCII tree connectors
assert "|--" in output or "`--" in output
def test_render_includes_status_in_brackets(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render includes status in brackets."""
renderer = PlainPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
assert "[completed" in output
def test_render_no_color_codes(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that plain renderer has no color codes."""
renderer = PlainPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
# Should not contain rich color codes
assert "[green]" not in output
assert "[red]" not in output
assert "[yellow]" not in output
class TestJsonPlanTreeRenderer:
"""Tests for JsonPlanTreeRenderer."""
def test_render_returns_valid_json(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render returns valid JSON."""
import json
renderer = JsonPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
# Should not raise
data = json.loads(output)
assert isinstance(data, dict)
def test_render_includes_plan_id(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that JSON includes plan ID."""
import json
renderer = JsonPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
data = json.loads(output)
assert data["plan_id"] == "plan-001"
def test_render_includes_root_node(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that JSON includes root node."""
import json
renderer = JsonPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
data = json.loads(output)
assert "root" in data
assert data["root"]["decision_id"] == "d-001"
def test_render_includes_children(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that JSON includes children."""
import json
renderer = JsonPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
data = json.loads(output)
root = data["root"]
assert "children" in root
assert len(root["children"]) == 1
assert root["children"][0]["decision_id"] == "d-002"
def test_render_includes_all_fields(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that JSON includes all required fields."""
import json
renderer = JsonPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
data = json.loads(output)
root = data["root"]
assert "decision_id" in root
assert "plan_id" in root
assert "type" in root
assert "timestamp" in root
assert "question" in root
assert "chosen_option" in root
assert "status" in root
assert "summary" in root
class TestGetRenderer:
"""Tests for get_renderer function."""
def test_get_renderer_returns_rich_renderer(self) -> None:
"""Test that get_renderer returns RichPlanTreeRenderer."""
renderer = get_renderer("rich")
assert isinstance(renderer, RichPlanTreeRenderer)
def test_get_renderer_returns_plain_renderer(self) -> None:
"""Test that get_renderer returns PlainPlanTreeRenderer."""
renderer = get_renderer("plain")
assert isinstance(renderer, PlainPlanTreeRenderer)
def test_get_renderer_returns_json_renderer(self) -> None:
"""Test that get_renderer returns JsonPlanTreeRenderer."""
renderer = get_renderer("json")
assert isinstance(renderer, JsonPlanTreeRenderer)
def test_get_renderer_raises_for_unknown_format(self) -> None:
"""Test that get_renderer raises for unknown format."""
with pytest.raises(ValueError):
get_renderer("unknown")