368 lines
13 KiB
Python
368 lines
13 KiB
Python
"""Unit tests for ReadFileTool — G5 (R22, R23) + characterization baseline.
|
|
|
|
Per plan U1 Execution note: characterization-first — assert that
|
|
`symbol=None` returns the full file content (matches pre-existing benchmark
|
|
`_FakeTool` shape) before adding symbol-extraction behavior.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import textwrap
|
|
|
|
import pytest
|
|
|
|
from agentkit.tools.file_read import ReadFileTool
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schema
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestReadFileToolSchema:
|
|
def test_name_is_read_file(self):
|
|
tool = ReadFileTool()
|
|
assert tool.name == "read_file"
|
|
|
|
def test_required_path(self):
|
|
tool = ReadFileTool()
|
|
assert "path" in tool.input_schema["required"]
|
|
assert "path" in tool.input_schema["properties"]
|
|
|
|
def test_optional_symbol_and_lines(self):
|
|
tool = ReadFileTool()
|
|
props = tool.input_schema["properties"]
|
|
assert "symbol" in props
|
|
assert "start_line" in props
|
|
assert "end_line" in props
|
|
# None of the optional fields should be in `required`.
|
|
required = set(tool.input_schema["required"])
|
|
assert required == {"path"}
|
|
|
|
def test_additional_properties_false(self):
|
|
# LLM tool-call schemas should reject unknown args (Wave 1 U3 pattern).
|
|
tool = ReadFileTool()
|
|
assert tool.input_schema.get("additionalProperties") is False
|
|
|
|
def test_tags_contain_io_and_read(self):
|
|
tool = ReadFileTool()
|
|
assert "io" in tool.tags
|
|
assert "read" in tool.tags
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Characterization — symbol=None returns full file
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_py_file(tmp_path):
|
|
path = tmp_path / "sample.py"
|
|
path.write_text(
|
|
textwrap.dedent('''
|
|
"""Sample module."""
|
|
|
|
def my_func():
|
|
return 42
|
|
|
|
|
|
class MyClass:
|
|
attr = 1
|
|
|
|
def method_a(self):
|
|
return self.attr
|
|
''').lstrip(),
|
|
encoding="utf-8",
|
|
)
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_ts_file(tmp_path):
|
|
path = tmp_path / "sample.ts"
|
|
path.write_text(
|
|
textwrap.dedent('''
|
|
export function renderComponent(): JSX.Element {
|
|
return <div/>;
|
|
}
|
|
|
|
export class BaseService {
|
|
abstract run(): void;
|
|
}
|
|
''').lstrip(),
|
|
encoding="utf-8",
|
|
)
|
|
return path
|
|
|
|
|
|
class TestCharacterizationFullFile:
|
|
"""symbol=None returns the whole file (matches _FakeTool baseline)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_file_returned_when_symbol_none(self, sample_py_file):
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(path=str(sample_py_file))
|
|
|
|
assert result["is_error"] is False
|
|
assert result["path"] == str(sample_py_file)
|
|
assert result["start_line"] == 1
|
|
assert result["end_line"] == result["total_lines"]
|
|
assert "def my_func" in result["content"]
|
|
assert "class MyClass" in result["content"]
|
|
assert result["content"].startswith('"""Sample module."""')
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_file_includes_all_lines(self, sample_py_file):
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(path=str(sample_py_file))
|
|
assert result["total_lines"] >= 8
|
|
assert result["content"].count("\n") >= result["total_lines"] - 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Symbol slicing — happy paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSymbolSlicing:
|
|
@pytest.mark.asyncio
|
|
async def test_python_function(self, sample_py_file):
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(path=str(sample_py_file), symbol="my_func")
|
|
|
|
assert result["is_error"] is False
|
|
assert result["symbol"] == "my_func"
|
|
assert result["symbol_kind"] == "function"
|
|
assert "def my_func" in result["content"]
|
|
assert "return 42" in result["content"]
|
|
# Should NOT include the class below.
|
|
assert "class MyClass" not in result["content"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_python_class_includes_method(self, sample_py_file):
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(path=str(sample_py_file), symbol="MyClass")
|
|
|
|
assert result["is_error"] is False
|
|
assert result["symbol"] == "MyClass"
|
|
assert result["symbol_kind"] == "class"
|
|
assert "class MyClass" in result["content"]
|
|
assert "def method_a" in result["content"] # method included
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_python_method_directly(self, sample_py_file):
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(path=str(sample_py_file), symbol="method_a")
|
|
|
|
assert result["is_error"] is False
|
|
assert result["symbol"] == "method_a"
|
|
assert result["symbol_kind"] == "method"
|
|
assert "def method_a" in result["content"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_typescript_function(self, sample_ts_file):
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(path=str(sample_ts_file), symbol="renderComponent")
|
|
|
|
assert result["is_error"] is False
|
|
assert result["symbol"] == "renderComponent"
|
|
assert "renderComponent" in result["content"]
|
|
# Should not include the class below.
|
|
assert "BaseService" not in result["content"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_typescript_class(self, sample_ts_file):
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(path=str(sample_ts_file), symbol="BaseService")
|
|
|
|
assert result["is_error"] is False
|
|
assert result["symbol"] == "BaseService"
|
|
assert result["symbol_kind"] == "class"
|
|
assert "BaseService" in result["content"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Symbol slicing — edge cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSymbolSlicingEdgeCases:
|
|
@pytest.mark.asyncio
|
|
async def test_symbol_not_found_lists_available(self, sample_py_file):
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(path=str(sample_py_file), symbol="nonexistent")
|
|
|
|
assert result["is_error"] is False # soft error, not hard
|
|
assert result["content"] == ""
|
|
assert result["symbol"] == "nonexistent"
|
|
available = result["available_symbols"]
|
|
assert "my_func" in available
|
|
assert "MyClass" in available
|
|
assert "method_a" in available
|
|
assert "nonexistent" not in result["content"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unsupported_extension_returns_full_with_note(self, tmp_path):
|
|
path = tmp_path / "notes.md"
|
|
path.write_text("# Hello\nworld\n", encoding="utf-8")
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(path=str(path), symbol="anything")
|
|
|
|
assert result["is_error"] is False
|
|
assert result["content"] == "# Hello\nworld\n"
|
|
assert "symbol extraction not supported" in result["note"]
|
|
assert ".md" in result["note"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_file(self, tmp_path):
|
|
path = tmp_path / "empty.py"
|
|
path.write_text("", encoding="utf-8")
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(path=str(path))
|
|
|
|
assert result["is_error"] is False
|
|
assert result["content"] == ""
|
|
assert result["total_lines"] == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_with_no_symbols(self, tmp_path):
|
|
path = tmp_path / "data.py"
|
|
path.write_text("# just a comment\nPI = 3.14\n", encoding="utf-8")
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(path=str(path), symbol="PI")
|
|
|
|
# PI is not a def/class — extractor finds no symbols; soft error lists available.
|
|
assert result["is_error"] is False
|
|
assert result["content"] == ""
|
|
assert result["available_symbols"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Error paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestReadFileToolErrors:
|
|
@pytest.mark.asyncio
|
|
async def test_path_required(self):
|
|
tool = ReadFileTool()
|
|
result = await tool.execute()
|
|
assert result["is_error"] is True
|
|
assert "path" in result["error"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_path_empty_string(self):
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(path="")
|
|
assert result["is_error"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_not_found(self, tmp_path):
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(path=str(tmp_path / "missing.py"))
|
|
assert result["is_error"] is True
|
|
assert "not found" in result["error"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_path_is_directory(self, tmp_path):
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(path=str(tmp_path))
|
|
assert result["is_error"] is True
|
|
assert "directory" in result["error"].lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Manual line slicing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestManualLineSlicing:
|
|
@pytest.mark.asyncio
|
|
async def test_start_and_end_line(self, sample_py_file):
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(
|
|
path=str(sample_py_file),
|
|
start_line=3,
|
|
end_line=5,
|
|
)
|
|
assert result["is_error"] is False
|
|
assert result["start_line"] == 3
|
|
assert result["end_line"] == 5
|
|
# Lines 3-5 of the sample file:
|
|
# line 3: "def my_func():"
|
|
# line 4: " return 42"
|
|
# line 5: "" (blank)
|
|
assert "def my_func" in result["content"]
|
|
assert "return 42" in result["content"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_line_only_extends_to_eof(self, sample_py_file):
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(path=str(sample_py_file), start_line=8)
|
|
assert result["is_error"] is False
|
|
assert result["start_line"] == 8
|
|
assert result["end_line"] == result["total_lines"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_end_line_only_starts_at_one(self, sample_py_file):
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(path=str(sample_py_file), end_line=2)
|
|
assert result["is_error"] is False
|
|
assert result["start_line"] == 1
|
|
assert result["end_line"] == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_start_line_zero(self, sample_py_file):
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(path=str(sample_py_file), start_line=0)
|
|
assert result["is_error"] is True
|
|
assert "start_line" in result["error"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_end_before_start(self, sample_py_file):
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(
|
|
path=str(sample_py_file),
|
|
start_line=5,
|
|
end_line=3,
|
|
)
|
|
assert result["is_error"] is True
|
|
assert "end_line" in result["error"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manual_lines_override_symbol(self, sample_py_file):
|
|
# Per plan U1 Approach: "start_line/end_line overrides symbol".
|
|
tool = ReadFileTool()
|
|
result = await tool.execute(
|
|
path=str(sample_py_file),
|
|
symbol="my_func",
|
|
start_line=1,
|
|
end_line=1,
|
|
)
|
|
assert result["is_error"] is False
|
|
# Manual slicing won — symbol field absent.
|
|
assert "symbol" not in result or result.get("symbol") is None
|
|
assert result["start_line"] == 1
|
|
assert result["end_line"] == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration — tool registry discovery
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestToolRegistryDiscovery:
|
|
def test_instantiable_without_args(self):
|
|
# Default constructor — matches the convention used by ToolRegistry
|
|
# to instantiate tools by class.
|
|
tool = ReadFileTool()
|
|
assert tool.name == "read_file"
|
|
|
|
def test_to_dict_serializable(self):
|
|
tool = ReadFileTool()
|
|
d = tool.to_dict()
|
|
assert d["name"] == "read_file"
|
|
assert "input_schema" in d
|
|
assert "output_schema" in d
|
|
assert d["tags"] == ["io", "file", "read"]
|