diff --git a/tests/timmy/test_tools_unit.py b/tests/timmy/test_tools_unit.py new file mode 100644 index 00000000..9b7e78c8 --- /dev/null +++ b/tests/timmy/test_tools_unit.py @@ -0,0 +1,255 @@ +"""Unit tests for timmy.tools — coverage gaps. + +Tests _make_smart_read_file, _safe_eval edge cases, consult_grok, +_create_stub_toolkit, get_tools_for_agent, and AiderTool edge cases. +""" + +from __future__ import annotations + +import ast +import math +from unittest.mock import MagicMock, patch + +import pytest + +from timmy.tools import ( + _create_stub_toolkit, + _safe_eval, + consult_grok, + create_aider_tool, + get_tools_for_agent, +) + +# ── _safe_eval edge cases ───────────────────────────────────────────────────── + + +class TestSafeEval: + """Edge cases for the AST-based safe evaluator.""" + + def _eval(self, expr: str): + allowed = {k: getattr(math, k) for k in dir(math) if not k.startswith("_")} + allowed["math"] = math + allowed["abs"] = abs + allowed["round"] = round + allowed["min"] = min + allowed["max"] = max + tree = ast.parse(expr, mode="eval") + return _safe_eval(tree, allowed) + + def test_unsupported_constant_type(self): + """String constants should be rejected.""" + with pytest.raises(ValueError, match="Unsupported constant"): + self._eval("'hello'") + + def test_unsupported_binary_op(self): + """Bitwise ops are not in the allowlist.""" + with pytest.raises(ValueError, match="Unsupported"): + self._eval("3 & 5") + + def test_unsupported_unary_op(self): + """Bitwise inversion is not supported.""" + with pytest.raises(ValueError, match="Unsupported"): + self._eval("~5") + + def test_unknown_name(self): + with pytest.raises(ValueError, match="Unknown name"): + self._eval("foo") + + def test_attribute_on_non_math(self): + """Attribute access on anything except the math module is blocked.""" + with pytest.raises(ValueError, match="Attribute access not allowed"): + self._eval("abs.__class__") + + def test_call_non_callable(self): + """Calling a non-callable (like a number) should fail.""" + with pytest.raises((ValueError, TypeError)): + self._eval("(42)()") + + def test_unsupported_syntax_subscript(self): + """Subscript syntax (a[0]) is not supported.""" + with pytest.raises(ValueError, match="Unsupported syntax"): + self._eval("[1, 2][0]") + + def test_kwargs_in_call(self): + """math.log with keyword arg should work through the evaluator.""" + result = self._eval("round(3.14159)") + assert result == 3 + + def test_math_attr_valid(self): + """Accessing a valid math attribute should work.""" + result = self._eval("math.pi") + assert result == math.pi + + def test_math_attr_invalid(self): + """Accessing a nonexistent math attribute should fail.""" + with pytest.raises(ValueError, match="Attribute access not allowed"): + self._eval("math.__builtins__") + + +# ── _make_smart_read_file ───────────────────────────────────────────────────── + + +class TestMakeSmartReadFile: + """Test the smart_read_file wrapper for directory detection.""" + + def test_directory_returns_listing(self, tmp_path): + """When given a directory, should list its contents.""" + (tmp_path / "alpha.txt").touch() + (tmp_path / "beta.py").touch() + (tmp_path / ".hidden").touch() # should be excluded + + from timmy.tools import _make_smart_read_file + + file_tools = MagicMock() + file_tools.check_escape.return_value = (True, tmp_path) + smart_read = _make_smart_read_file(file_tools) + + result = smart_read(file_name=str(tmp_path)) + assert "is a directory" in result + assert "alpha.txt" in result + assert "beta.py" in result + assert ".hidden" not in result + + def test_empty_directory(self, tmp_path): + """Empty directory should show placeholder.""" + from timmy.tools import _make_smart_read_file + + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + + file_tools = MagicMock() + file_tools.check_escape.return_value = (True, empty_dir) + smart_read = _make_smart_read_file(file_tools) + + result = smart_read(file_name=str(empty_dir)) + assert "empty directory" in result + + def test_no_file_name_uses_path_kwarg(self): + """When file_name is empty, should fall back to path= kwarg.""" + from timmy.tools import _make_smart_read_file + + file_tools = MagicMock() + file_tools.check_escape.return_value = (True, MagicMock(is_dir=lambda: False)) + file_tools.read_file.return_value = "file content" + smart_read = _make_smart_read_file(file_tools) + + smart_read(path="/some/file.txt") + file_tools.read_file.assert_called_once() + + def test_no_file_name_no_path(self): + """When neither file_name nor path is given, return error.""" + from timmy.tools import _make_smart_read_file + + file_tools = MagicMock() + smart_read = _make_smart_read_file(file_tools) + + result = smart_read() + assert "Error" in result + + def test_file_delegates_to_original(self, tmp_path): + """Regular files should delegate to original read_file.""" + from timmy.tools import _make_smart_read_file + + f = tmp_path / "hello.txt" + f.write_text("hello world") + + file_tools = MagicMock() + file_tools.check_escape.return_value = (True, f) + file_tools.read_file.return_value = "hello world" + smart_read = _make_smart_read_file(file_tools) + + result = smart_read(file_name=str(f)) + assert result == "hello world" + + def test_preserves_docstring(self): + """smart_read_file should copy the original's docstring.""" + from timmy.tools import _make_smart_read_file + + file_tools = MagicMock() + file_tools.read_file.__doc__ = "Original docstring." + smart_read = _make_smart_read_file(file_tools) + assert smart_read.__doc__ == "Original docstring." + + +# ── consult_grok ────────────────────────────────────────────────────────────── + + +class TestConsultGrok: + """Test the Grok consultation tool.""" + + @patch("timmy.tools.settings") + def test_grok_unavailable(self, mock_settings): + """When Grok is disabled, should return a helpful message.""" + with patch("timmy.backends.grok_available", return_value=False): + result = consult_grok("What is 2+2?") + assert "not available" in result.lower() + + +# ── _create_stub_toolkit ────────────────────────────────────────────────────── + + +class TestCreateStubToolkit: + """Test stub toolkit creation for creative agents.""" + + def test_stub_has_correct_name(self): + toolkit = _create_stub_toolkit("pixel") + if toolkit is None: + pytest.skip("Agno tools not available") + assert toolkit.name == "pixel" + + def test_stub_for_different_agent(self): + toolkit = _create_stub_toolkit("lyra") + if toolkit is None: + pytest.skip("Agno tools not available") + assert toolkit.name == "lyra" + + +# ── get_tools_for_agent ─────────────────────────────────────────────────────── + + +class TestGetToolsForAgent: + """Test get_tools_for_agent (not just the alias).""" + + def test_known_agent_returns_toolkit(self): + result = get_tools_for_agent("echo") + assert result is not None + + def test_unknown_agent_returns_none(self): + result = get_tools_for_agent("nonexistent") + assert result is None + + def test_custom_base_dir(self, tmp_path): + result = get_tools_for_agent("echo", base_dir=tmp_path) + assert result is not None + + +# ── AiderTool edge cases ───────────────────────────────────────────────────── + + +class TestAiderToolEdgeCases: + """Additional edge cases for the AiderTool.""" + + @patch("subprocess.run") + def test_aider_success_empty_stdout(self, mock_run, tmp_path): + """When stdout is empty, should return fallback message.""" + mock_run.return_value = MagicMock(returncode=0, stdout="") + tool = create_aider_tool(tmp_path) + result = tool.run_aider("do something") + assert "successfully" in result.lower() + + @patch("subprocess.run") + def test_aider_custom_model(self, mock_run, tmp_path): + """Custom model parameter should be passed to subprocess.""" + mock_run.return_value = MagicMock(returncode=0, stdout="done") + tool = create_aider_tool(tmp_path) + tool.run_aider("task", model="deepseek-coder:6.7b") + args = mock_run.call_args[0][0] + assert "ollama/deepseek-coder:6.7b" in args + + @patch("subprocess.run") + def test_aider_os_error(self, mock_run, tmp_path): + """OSError should be caught gracefully.""" + mock_run.side_effect = OSError("Permission denied") + tool = create_aider_tool(tmp_path) + result = tool.run_aider("task") + assert "error" in result.lower()