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