mirror of
https://github.com/shareAI-lab/analysis_claude_code.git
synced 2026-02-04 13:16:37 +08:00
Unit tests (25 tests): - TodoManager edge cases: empty list, status transitions, missing fields, invalid status, render format - v3 subagent: AGENT_TYPES structure, get_tools_for_agent, get_agent_descriptions, Task tool schema - v4 skills: SkillLoader init, parse valid/invalid SKILL.md, get_skill_content, list_skills, Skill tool schema - Security: safe_path path traversal prevention - Config: ANTHROPIC_BASE_URL support Integration tests (21 tests): - v0: bash echo, bash pipeline - v1: read_file, write_file, edit_file, read_edit_verify - v2: TodoWrite single task, TodoWrite multi-step - Error handling: file not found, command fails, edit string not found - Workflows: create Python script, find and replace, directory setup - Edge cases: unicode content, empty file, special chars, multiline edit, nested directory, large output, concurrent files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
645 lines
19 KiB
Python
645 lines
19 KiB
Python
"""
|
|
Unit tests for learn-claude-code agents.
|
|
|
|
These tests don't require API calls - they verify code structure and logic.
|
|
"""
|
|
import os
|
|
import sys
|
|
import importlib.util
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
|
|
# =============================================================================
|
|
# Import Tests
|
|
# =============================================================================
|
|
|
|
def test_imports():
|
|
"""Test that all agent modules can be imported."""
|
|
agents = [
|
|
"v0_bash_agent",
|
|
"v0_bash_agent_mini",
|
|
"v1_basic_agent",
|
|
"v2_todo_agent",
|
|
"v3_subagent",
|
|
"v4_skills_agent"
|
|
]
|
|
|
|
for agent in agents:
|
|
spec = importlib.util.find_spec(agent)
|
|
assert spec is not None, f"Failed to find {agent}"
|
|
print(f" Found: {agent}")
|
|
|
|
print("PASS: test_imports")
|
|
return True
|
|
|
|
|
|
# =============================================================================
|
|
# TodoManager Tests
|
|
# =============================================================================
|
|
|
|
def test_todo_manager_basic():
|
|
"""Test TodoManager basic operations."""
|
|
from v2_todo_agent import TodoManager
|
|
|
|
tm = TodoManager()
|
|
|
|
# Test valid update
|
|
result = tm.update([
|
|
{"content": "Task 1", "status": "pending", "activeForm": "Doing task 1"},
|
|
{"content": "Task 2", "status": "in_progress", "activeForm": "Doing task 2"},
|
|
])
|
|
|
|
assert "Task 1" in result
|
|
assert "Task 2" in result
|
|
assert len(tm.items) == 2
|
|
|
|
print("PASS: test_todo_manager_basic")
|
|
return True
|
|
|
|
|
|
def test_todo_manager_constraints():
|
|
"""Test TodoManager enforces constraints."""
|
|
from v2_todo_agent import TodoManager
|
|
|
|
tm = TodoManager()
|
|
|
|
# Test: only one in_progress allowed (should raise or return error)
|
|
try:
|
|
result = tm.update([
|
|
{"content": "Task 1", "status": "in_progress", "activeForm": "Doing 1"},
|
|
{"content": "Task 2", "status": "in_progress", "activeForm": "Doing 2"},
|
|
])
|
|
# If no exception, check result contains error
|
|
assert "Error" in result or "error" in result.lower()
|
|
except ValueError as e:
|
|
# Exception is expected - constraint enforced
|
|
assert "in_progress" in str(e).lower()
|
|
|
|
# Test: max 20 items
|
|
tm2 = TodoManager()
|
|
many_items = [{"content": f"Task {i}", "status": "pending", "activeForm": f"Doing {i}"} for i in range(25)]
|
|
try:
|
|
tm2.update(many_items)
|
|
except ValueError:
|
|
pass # Exception is fine
|
|
assert len(tm2.items) <= 20
|
|
|
|
print("PASS: test_todo_manager_constraints")
|
|
return True
|
|
|
|
|
|
# =============================================================================
|
|
# Reminder Tests
|
|
# =============================================================================
|
|
|
|
def test_reminder_constants():
|
|
"""Test reminder constants are defined correctly."""
|
|
from v2_todo_agent import INITIAL_REMINDER, NAG_REMINDER
|
|
|
|
assert "<reminder>" in INITIAL_REMINDER
|
|
assert "</reminder>" in INITIAL_REMINDER
|
|
assert "<reminder>" in NAG_REMINDER
|
|
assert "</reminder>" in NAG_REMINDER
|
|
assert "todo" in NAG_REMINDER.lower() or "Todo" in NAG_REMINDER
|
|
|
|
print("PASS: test_reminder_constants")
|
|
return True
|
|
|
|
|
|
def test_nag_reminder_in_agent_loop():
|
|
"""Test NAG_REMINDER injection is inside agent_loop."""
|
|
import inspect
|
|
from v2_todo_agent import agent_loop, NAG_REMINDER
|
|
|
|
source = inspect.getsource(agent_loop)
|
|
|
|
# NAG_REMINDER should be referenced in agent_loop
|
|
assert "NAG_REMINDER" in source, "NAG_REMINDER should be in agent_loop"
|
|
assert "rounds_without_todo" in source, "rounds_without_todo check should be in agent_loop"
|
|
assert "results.insert" in source or "results.append" in source, "Should inject into results"
|
|
|
|
print("PASS: test_nag_reminder_in_agent_loop")
|
|
return True
|
|
|
|
|
|
# =============================================================================
|
|
# Configuration Tests
|
|
# =============================================================================
|
|
|
|
def test_env_config():
|
|
"""Test environment variable configuration."""
|
|
# Save original values
|
|
orig_model = os.environ.get("MODEL_ID")
|
|
orig_base = os.environ.get("ANTHROPIC_BASE_URL")
|
|
|
|
try:
|
|
# Set test values
|
|
os.environ["MODEL_ID"] = "test-model-123"
|
|
os.environ["ANTHROPIC_BASE_URL"] = "https://test.example.com"
|
|
|
|
# Re-import to pick up new env vars
|
|
import importlib
|
|
import v1_basic_agent
|
|
importlib.reload(v1_basic_agent)
|
|
|
|
assert v1_basic_agent.MODEL == "test-model-123", f"MODEL should be test-model-123, got {v1_basic_agent.MODEL}"
|
|
|
|
print("PASS: test_env_config")
|
|
return True
|
|
|
|
finally:
|
|
# Restore original values
|
|
if orig_model:
|
|
os.environ["MODEL_ID"] = orig_model
|
|
else:
|
|
os.environ.pop("MODEL_ID", None)
|
|
if orig_base:
|
|
os.environ["ANTHROPIC_BASE_URL"] = orig_base
|
|
else:
|
|
os.environ.pop("ANTHROPIC_BASE_URL", None)
|
|
|
|
|
|
def test_default_model():
|
|
"""Test default model when env var not set."""
|
|
orig = os.environ.pop("MODEL_ID", None)
|
|
|
|
try:
|
|
import importlib
|
|
import v1_basic_agent
|
|
importlib.reload(v1_basic_agent)
|
|
|
|
assert "claude" in v1_basic_agent.MODEL.lower(), f"Default model should contain 'claude': {v1_basic_agent.MODEL}"
|
|
|
|
print("PASS: test_default_model")
|
|
return True
|
|
|
|
finally:
|
|
if orig:
|
|
os.environ["MODEL_ID"] = orig
|
|
|
|
|
|
# =============================================================================
|
|
# Tool Schema Tests
|
|
# =============================================================================
|
|
|
|
def test_tool_schemas():
|
|
"""Test tool schemas are valid."""
|
|
from v1_basic_agent import TOOLS
|
|
|
|
required_tools = {"bash", "read_file", "write_file", "edit_file"}
|
|
tool_names = {t["name"] for t in TOOLS}
|
|
|
|
assert required_tools.issubset(tool_names), f"Missing tools: {required_tools - tool_names}"
|
|
|
|
for tool in TOOLS:
|
|
assert "name" in tool
|
|
assert "description" in tool
|
|
assert "input_schema" in tool
|
|
assert tool["input_schema"].get("type") == "object"
|
|
|
|
print("PASS: test_tool_schemas")
|
|
return True
|
|
|
|
|
|
# =============================================================================
|
|
# TodoManager Edge Case Tests
|
|
# =============================================================================
|
|
|
|
def test_todo_manager_empty_list():
|
|
"""Test TodoManager handles empty list."""
|
|
from v2_todo_agent import TodoManager
|
|
|
|
tm = TodoManager()
|
|
result = tm.update([])
|
|
|
|
assert "No todos" in result or len(tm.items) == 0
|
|
print("PASS: test_todo_manager_empty_list")
|
|
return True
|
|
|
|
|
|
def test_todo_manager_status_transitions():
|
|
"""Test TodoManager status transitions."""
|
|
from v2_todo_agent import TodoManager
|
|
|
|
tm = TodoManager()
|
|
|
|
# Start with pending
|
|
tm.update([{"content": "Task", "status": "pending", "activeForm": "Doing task"}])
|
|
assert tm.items[0]["status"] == "pending"
|
|
|
|
# Move to in_progress
|
|
tm.update([{"content": "Task", "status": "in_progress", "activeForm": "Doing task"}])
|
|
assert tm.items[0]["status"] == "in_progress"
|
|
|
|
# Complete
|
|
tm.update([{"content": "Task", "status": "completed", "activeForm": "Doing task"}])
|
|
assert tm.items[0]["status"] == "completed"
|
|
|
|
print("PASS: test_todo_manager_status_transitions")
|
|
return True
|
|
|
|
|
|
def test_todo_manager_missing_fields():
|
|
"""Test TodoManager rejects items with missing fields."""
|
|
from v2_todo_agent import TodoManager
|
|
|
|
tm = TodoManager()
|
|
|
|
# Missing content
|
|
try:
|
|
tm.update([{"status": "pending", "activeForm": "Doing"}])
|
|
assert False, "Should reject missing content"
|
|
except ValueError:
|
|
pass
|
|
|
|
# Missing activeForm
|
|
try:
|
|
tm.update([{"content": "Task", "status": "pending"}])
|
|
assert False, "Should reject missing activeForm"
|
|
except ValueError:
|
|
pass
|
|
|
|
print("PASS: test_todo_manager_missing_fields")
|
|
return True
|
|
|
|
|
|
def test_todo_manager_invalid_status():
|
|
"""Test TodoManager rejects invalid status values."""
|
|
from v2_todo_agent import TodoManager
|
|
|
|
tm = TodoManager()
|
|
|
|
try:
|
|
tm.update([{"content": "Task", "status": "invalid", "activeForm": "Doing"}])
|
|
assert False, "Should reject invalid status"
|
|
except ValueError as e:
|
|
assert "status" in str(e).lower()
|
|
|
|
print("PASS: test_todo_manager_invalid_status")
|
|
return True
|
|
|
|
|
|
def test_todo_manager_render_format():
|
|
"""Test TodoManager render format."""
|
|
from v2_todo_agent import TodoManager
|
|
|
|
tm = TodoManager()
|
|
tm.update([
|
|
{"content": "Task A", "status": "completed", "activeForm": "A"},
|
|
{"content": "Task B", "status": "in_progress", "activeForm": "B"},
|
|
{"content": "Task C", "status": "pending", "activeForm": "C"},
|
|
])
|
|
|
|
result = tm.render()
|
|
assert "[x] Task A" in result
|
|
assert "[>] Task B" in result
|
|
assert "[ ] Task C" in result
|
|
assert "1/3" in result # Format may vary: "done" or "completed"
|
|
|
|
print("PASS: test_todo_manager_render_format")
|
|
return True
|
|
|
|
|
|
# =============================================================================
|
|
# v3 Agent Type Registry Tests
|
|
# =============================================================================
|
|
|
|
def test_v3_agent_types_structure():
|
|
"""Test v3 AGENT_TYPES structure."""
|
|
from v3_subagent import AGENT_TYPES
|
|
|
|
required_types = {"explore", "code", "plan"}
|
|
assert set(AGENT_TYPES.keys()) == required_types
|
|
|
|
for name, config in AGENT_TYPES.items():
|
|
assert "description" in config, f"{name} missing description"
|
|
assert "tools" in config, f"{name} missing tools"
|
|
assert "prompt" in config, f"{name} missing prompt"
|
|
|
|
print("PASS: test_v3_agent_types_structure")
|
|
return True
|
|
|
|
|
|
def test_v3_get_tools_for_agent():
|
|
"""Test v3 get_tools_for_agent filters correctly."""
|
|
from v3_subagent import get_tools_for_agent, BASE_TOOLS
|
|
|
|
# explore: read-only
|
|
explore_tools = get_tools_for_agent("explore")
|
|
explore_names = {t["name"] for t in explore_tools}
|
|
assert "bash" in explore_names
|
|
assert "read_file" in explore_names
|
|
assert "write_file" not in explore_names
|
|
assert "edit_file" not in explore_names
|
|
|
|
# code: all base tools
|
|
code_tools = get_tools_for_agent("code")
|
|
assert len(code_tools) == len(BASE_TOOLS)
|
|
|
|
# plan: read-only
|
|
plan_tools = get_tools_for_agent("plan")
|
|
plan_names = {t["name"] for t in plan_tools}
|
|
assert "write_file" not in plan_names
|
|
|
|
print("PASS: test_v3_get_tools_for_agent")
|
|
return True
|
|
|
|
|
|
def test_v3_get_agent_descriptions():
|
|
"""Test v3 get_agent_descriptions output."""
|
|
from v3_subagent import get_agent_descriptions
|
|
|
|
desc = get_agent_descriptions()
|
|
assert "explore" in desc
|
|
assert "code" in desc
|
|
assert "plan" in desc
|
|
assert "Read-only" in desc or "read" in desc.lower()
|
|
|
|
print("PASS: test_v3_get_agent_descriptions")
|
|
return True
|
|
|
|
|
|
def test_v3_task_tool_schema():
|
|
"""Test v3 Task tool schema."""
|
|
from v3_subagent import TASK_TOOL, AGENT_TYPES
|
|
|
|
assert TASK_TOOL["name"] == "Task"
|
|
schema = TASK_TOOL["input_schema"]
|
|
assert "description" in schema["properties"]
|
|
assert "prompt" in schema["properties"]
|
|
assert "agent_type" in schema["properties"]
|
|
assert set(schema["properties"]["agent_type"]["enum"]) == set(AGENT_TYPES.keys())
|
|
|
|
print("PASS: test_v3_task_tool_schema")
|
|
return True
|
|
|
|
|
|
# =============================================================================
|
|
# v4 SkillLoader Tests
|
|
# =============================================================================
|
|
|
|
def test_v4_skill_loader_init():
|
|
"""Test v4 SkillLoader initialization."""
|
|
from v4_skills_agent import SkillLoader
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
# Empty skills dir
|
|
loader = SkillLoader(Path(tmpdir))
|
|
assert len(loader.skills) == 0
|
|
|
|
print("PASS: test_v4_skill_loader_init")
|
|
return True
|
|
|
|
|
|
def test_v4_skill_loader_parse_valid():
|
|
"""Test v4 SkillLoader parses valid SKILL.md."""
|
|
from v4_skills_agent import SkillLoader
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
skill_dir = Path(tmpdir) / "test-skill"
|
|
skill_dir.mkdir()
|
|
|
|
skill_md = skill_dir / "SKILL.md"
|
|
skill_md.write_text("""---
|
|
name: test
|
|
description: A test skill for testing
|
|
---
|
|
|
|
# Test Skill
|
|
|
|
This is the body content.
|
|
""")
|
|
|
|
loader = SkillLoader(Path(tmpdir))
|
|
assert "test" in loader.skills
|
|
assert loader.skills["test"]["description"] == "A test skill for testing"
|
|
assert "body content" in loader.skills["test"]["body"]
|
|
|
|
print("PASS: test_v4_skill_loader_parse_valid")
|
|
return True
|
|
|
|
|
|
def test_v4_skill_loader_parse_invalid():
|
|
"""Test v4 SkillLoader rejects invalid SKILL.md."""
|
|
from v4_skills_agent import SkillLoader
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
skill_dir = Path(tmpdir) / "bad-skill"
|
|
skill_dir.mkdir()
|
|
|
|
# Missing frontmatter
|
|
skill_md = skill_dir / "SKILL.md"
|
|
skill_md.write_text("# No frontmatter\n\nJust content.")
|
|
|
|
loader = SkillLoader(Path(tmpdir))
|
|
assert "bad-skill" not in loader.skills
|
|
|
|
print("PASS: test_v4_skill_loader_parse_invalid")
|
|
return True
|
|
|
|
|
|
def test_v4_skill_loader_get_content():
|
|
"""Test v4 SkillLoader get_skill_content."""
|
|
from v4_skills_agent import SkillLoader
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
skill_dir = Path(tmpdir) / "demo"
|
|
skill_dir.mkdir()
|
|
|
|
(skill_dir / "SKILL.md").write_text("""---
|
|
name: demo
|
|
description: Demo skill
|
|
---
|
|
|
|
# Demo Instructions
|
|
|
|
Step 1: Do this
|
|
Step 2: Do that
|
|
""")
|
|
|
|
# Add resources
|
|
scripts_dir = skill_dir / "scripts"
|
|
scripts_dir.mkdir()
|
|
(scripts_dir / "helper.sh").write_text("#!/bin/bash\necho hello")
|
|
|
|
loader = SkillLoader(Path(tmpdir))
|
|
|
|
content = loader.get_skill_content("demo")
|
|
assert content is not None
|
|
assert "Demo Instructions" in content
|
|
assert "helper.sh" in content # Resources listed
|
|
|
|
# Non-existent skill
|
|
assert loader.get_skill_content("nonexistent") is None
|
|
|
|
print("PASS: test_v4_skill_loader_get_content")
|
|
return True
|
|
|
|
|
|
def test_v4_skill_loader_list_skills():
|
|
"""Test v4 SkillLoader list_skills."""
|
|
from v4_skills_agent import SkillLoader
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
# Create two skills
|
|
for name in ["alpha", "beta"]:
|
|
skill_dir = Path(tmpdir) / name
|
|
skill_dir.mkdir()
|
|
(skill_dir / "SKILL.md").write_text(f"""---
|
|
name: {name}
|
|
description: {name} skill
|
|
---
|
|
|
|
Content for {name}
|
|
""")
|
|
|
|
loader = SkillLoader(Path(tmpdir))
|
|
skills = loader.list_skills()
|
|
assert "alpha" in skills
|
|
assert "beta" in skills
|
|
assert len(skills) == 2
|
|
|
|
print("PASS: test_v4_skill_loader_list_skills")
|
|
return True
|
|
|
|
|
|
def test_v4_skill_tool_schema():
|
|
"""Test v4 Skill tool schema."""
|
|
from v4_skills_agent import SKILL_TOOL
|
|
|
|
assert SKILL_TOOL["name"] == "Skill"
|
|
schema = SKILL_TOOL["input_schema"]
|
|
assert "skill" in schema["properties"]
|
|
assert "skill" in schema["required"]
|
|
|
|
print("PASS: test_v4_skill_tool_schema")
|
|
return True
|
|
|
|
|
|
# =============================================================================
|
|
# Path Safety Tests
|
|
# =============================================================================
|
|
|
|
def test_v3_safe_path():
|
|
"""Test v3 safe_path prevents path traversal."""
|
|
from v3_subagent import safe_path, WORKDIR
|
|
|
|
# Valid path
|
|
p = safe_path("test.txt")
|
|
assert str(p).startswith(str(WORKDIR))
|
|
|
|
# Path traversal attempt
|
|
try:
|
|
safe_path("../../../etc/passwd")
|
|
assert False, "Should reject path traversal"
|
|
except ValueError as e:
|
|
assert "escape" in str(e).lower()
|
|
|
|
print("PASS: test_v3_safe_path")
|
|
return True
|
|
|
|
|
|
# =============================================================================
|
|
# Configuration Tests (Extended)
|
|
# =============================================================================
|
|
|
|
def test_base_url_config():
|
|
"""Test ANTHROPIC_BASE_URL configuration."""
|
|
orig = os.environ.get("ANTHROPIC_BASE_URL")
|
|
|
|
try:
|
|
os.environ["ANTHROPIC_BASE_URL"] = "https://custom.api.com"
|
|
|
|
import importlib
|
|
import v1_basic_agent
|
|
importlib.reload(v1_basic_agent)
|
|
|
|
# Check client was created (we can't easily verify base_url without mocking)
|
|
assert v1_basic_agent.client is not None
|
|
|
|
print("PASS: test_base_url_config")
|
|
return True
|
|
|
|
finally:
|
|
if orig:
|
|
os.environ["ANTHROPIC_BASE_URL"] = orig
|
|
else:
|
|
os.environ.pop("ANTHROPIC_BASE_URL", None)
|
|
|
|
|
|
# =============================================================================
|
|
# Main
|
|
# =============================================================================
|
|
|
|
if __name__ == "__main__":
|
|
tests = [
|
|
# Basic tests
|
|
test_imports,
|
|
test_todo_manager_basic,
|
|
test_todo_manager_constraints,
|
|
test_reminder_constants,
|
|
test_nag_reminder_in_agent_loop,
|
|
test_env_config,
|
|
test_default_model,
|
|
test_tool_schemas,
|
|
# TodoManager edge cases
|
|
test_todo_manager_empty_list,
|
|
test_todo_manager_status_transitions,
|
|
test_todo_manager_missing_fields,
|
|
test_todo_manager_invalid_status,
|
|
test_todo_manager_render_format,
|
|
# v3 tests
|
|
test_v3_agent_types_structure,
|
|
test_v3_get_tools_for_agent,
|
|
test_v3_get_agent_descriptions,
|
|
test_v3_task_tool_schema,
|
|
# v4 tests
|
|
test_v4_skill_loader_init,
|
|
test_v4_skill_loader_parse_valid,
|
|
test_v4_skill_loader_parse_invalid,
|
|
test_v4_skill_loader_get_content,
|
|
test_v4_skill_loader_list_skills,
|
|
test_v4_skill_tool_schema,
|
|
# Security tests
|
|
test_v3_safe_path,
|
|
# Config tests
|
|
test_base_url_config,
|
|
]
|
|
|
|
failed = []
|
|
for test_fn in tests:
|
|
name = test_fn.__name__
|
|
print(f"\n{'='*50}")
|
|
print(f"Running: {name}")
|
|
print('='*50)
|
|
try:
|
|
if not test_fn():
|
|
failed.append(name)
|
|
except Exception as e:
|
|
print(f"FAILED: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
failed.append(name)
|
|
|
|
print(f"\n{'='*50}")
|
|
print(f"Results: {len(tests) - len(failed)}/{len(tests)} passed")
|
|
print('='*50)
|
|
|
|
if failed:
|
|
print(f"FAILED: {failed}")
|
|
sys.exit(1)
|
|
else:
|
|
print("All unit tests passed!")
|
|
sys.exit(0)
|