fischer-agentkit/tests/unit/test_read_file_tool.py

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"]