"""Unit tests for src/timmy/research.py — ResearchOrchestrator pipeline. Refs #972 (governing spec), #975 (ResearchOrchestrator). """ from __future__ import annotations from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest pytestmark = pytest.mark.unit # --------------------------------------------------------------------------- # list_templates # --------------------------------------------------------------------------- class TestListTemplates: def test_returns_list(self, tmp_path, monkeypatch): (tmp_path / "tool_evaluation.md").write_text("---\n---\n# T") (tmp_path / "game_analysis.md").write_text("---\n---\n# G") monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path) from timmy.research import list_templates result = list_templates() assert isinstance(result, list) assert "tool_evaluation" in result assert "game_analysis" in result def test_returns_empty_when_dir_missing(self, tmp_path, monkeypatch): monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path / "nonexistent") from timmy.research import list_templates assert list_templates() == [] # --------------------------------------------------------------------------- # load_template # --------------------------------------------------------------------------- class TestLoadTemplate: def _write_template(self, path: Path, name: str, body: str) -> None: (path / f"{name}.md").write_text(body, encoding="utf-8") def test_loads_and_strips_frontmatter(self, tmp_path, monkeypatch): self._write_template( tmp_path, "tool_evaluation", "---\nname: Tool Evaluation\ntype: research\n---\n# Tool Eval: {domain}", ) monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path) from timmy.research import load_template result = load_template("tool_evaluation", {"domain": "PDF parsing"}) assert "# Tool Eval: PDF parsing" in result assert "name: Tool Evaluation" not in result def test_fills_slots(self, tmp_path, monkeypatch): self._write_template(tmp_path, "arch", "Connect {system_a} to {system_b}") monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path) from timmy.research import load_template result = load_template("arch", {"system_a": "Kafka", "system_b": "Postgres"}) assert "Kafka" in result assert "Postgres" in result def test_unfilled_slots_preserved(self, tmp_path, monkeypatch): self._write_template(tmp_path, "t", "Hello {name} and {other}") monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path) from timmy.research import load_template result = load_template("t", {"name": "World"}) assert "{other}" in result def test_raises_file_not_found_for_missing_template(self, tmp_path, monkeypatch): monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path) from timmy.research import load_template with pytest.raises(FileNotFoundError, match="nonexistent"): load_template("nonexistent") def test_no_slots_returns_raw_body(self, tmp_path, monkeypatch): self._write_template(tmp_path, "plain", "---\n---\nJust text here") monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path) from timmy.research import load_template result = load_template("plain") assert result == "Just text here" # --------------------------------------------------------------------------- # _check_cache # --------------------------------------------------------------------------- class TestCheckCache: def test_returns_none_when_no_hits(self): mock_mem = MagicMock() mock_mem.search.return_value = [] with patch("timmy.research.SemanticMemory", return_value=mock_mem): from timmy.research import _check_cache content, score = _check_cache("some topic") assert content is None assert score == 0.0 def test_returns_content_above_threshold(self): mock_mem = MagicMock() mock_mem.search.return_value = [("cached report text", 0.91)] with patch("timmy.research.SemanticMemory", return_value=mock_mem): from timmy.research import _check_cache content, score = _check_cache("same topic") assert content == "cached report text" assert score == pytest.approx(0.91) def test_returns_none_below_threshold(self): mock_mem = MagicMock() mock_mem.search.return_value = [("old report", 0.60)] with patch("timmy.research.SemanticMemory", return_value=mock_mem): from timmy.research import _check_cache content, score = _check_cache("slightly different topic") assert content is None assert score == 0.0 def test_degrades_gracefully_on_import_error(self): with patch("timmy.research.SemanticMemory", None): from timmy.research import _check_cache content, score = _check_cache("topic") assert content is None assert score == 0.0 # --------------------------------------------------------------------------- # _store_result # --------------------------------------------------------------------------- class TestStoreResult: def test_calls_store_memory(self): mock_store = MagicMock() with patch("timmy.research.store_memory", mock_store): from timmy.research import _store_result _store_result("test topic", "# Report\n\nContent here.") mock_store.assert_called_once() call_kwargs = mock_store.call_args assert "test topic" in str(call_kwargs) def test_degrades_gracefully_on_error(self): mock_store = MagicMock(side_effect=RuntimeError("db error")) with patch("timmy.research.store_memory", mock_store): from timmy.research import _store_result # Should not raise _store_result("topic", "report") # --------------------------------------------------------------------------- # _save_to_disk # --------------------------------------------------------------------------- class TestSaveToDisk: def test_writes_file(self, tmp_path, monkeypatch): monkeypatch.setattr("timmy.research._DOCS_ROOT", tmp_path / "research") from timmy.research import _save_to_disk path = _save_to_disk("Test Topic: PDF Parsing", "# Test Report") assert path is not None assert path.exists() assert path.read_text() == "# Test Report" def test_slugifies_topic_name(self, tmp_path, monkeypatch): monkeypatch.setattr("timmy.research._DOCS_ROOT", tmp_path / "research") from timmy.research import _save_to_disk path = _save_to_disk("My Complex Topic! v2.0", "content") assert path is not None # Should be slugified: no special chars assert " " not in path.name assert "!" not in path.name def test_returns_none_on_error(self, monkeypatch): monkeypatch.setattr( "timmy.research._DOCS_ROOT", Path("/nonexistent_root/deeply/nested"), ) with patch("pathlib.Path.mkdir", side_effect=PermissionError("denied")): from timmy.research import _save_to_disk result = _save_to_disk("topic", "report") assert result is None # --------------------------------------------------------------------------- # run_research — end-to-end with mocks # --------------------------------------------------------------------------- class TestRunResearch: @pytest.mark.asyncio async def test_returns_cached_result_when_cache_hit(self): cached_report = "# Cached Report\n\nPreviously computed." with ( patch("timmy.research._check_cache", return_value=(cached_report, 0.93)), ): from timmy.research import run_research result = await run_research("some topic") assert result.cached is True assert result.cache_similarity == pytest.approx(0.93) assert result.report == cached_report assert result.synthesis_backend == "cache" @pytest.mark.asyncio async def test_skips_cache_when_requested(self, tmp_path, monkeypatch): monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path) with ( patch("timmy.research._check_cache", return_value=("cached", 0.99)) as mock_cache, patch( "timmy.research._formulate_queries", new=AsyncMock(return_value=["q1"]), ), patch("timmy.research._execute_search", new=AsyncMock(return_value=[])), patch("timmy.research._fetch_pages", new=AsyncMock(return_value=[])), patch( "timmy.research._synthesize", new=AsyncMock(return_value=("# Fresh report", "ollama")), ), patch("timmy.research._store_result"), ): from timmy.research import run_research result = await run_research("topic", skip_cache=True) mock_cache.assert_not_called() assert result.cached is False assert result.report == "# Fresh report" @pytest.mark.asyncio async def test_full_pipeline_no_search_results(self, tmp_path, monkeypatch): monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path) with ( patch("timmy.research._check_cache", return_value=(None, 0.0)), patch( "timmy.research._formulate_queries", new=AsyncMock(return_value=["query 1", "query 2"]), ), patch("timmy.research._execute_search", new=AsyncMock(return_value=[])), patch("timmy.research._fetch_pages", new=AsyncMock(return_value=[])), patch( "timmy.research._synthesize", new=AsyncMock(return_value=("# Report", "ollama")), ), patch("timmy.research._store_result"), ): from timmy.research import run_research result = await run_research("a new topic") assert not result.cached assert result.query_count == 2 assert result.sources_fetched == 0 assert result.report == "# Report" assert result.synthesis_backend == "ollama" @pytest.mark.asyncio async def test_returns_result_with_error_on_bad_template(self, tmp_path, monkeypatch): monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path) with ( patch("timmy.research._check_cache", return_value=(None, 0.0)), patch( "timmy.research._formulate_queries", new=AsyncMock(return_value=["q1"]), ), patch("timmy.research._execute_search", new=AsyncMock(return_value=[])), patch("timmy.research._fetch_pages", new=AsyncMock(return_value=[])), patch( "timmy.research._synthesize", new=AsyncMock(return_value=("# Report", "ollama")), ), patch("timmy.research._store_result"), ): from timmy.research import run_research result = await run_research("topic", template="nonexistent_template") assert len(result.errors) == 1 assert "nonexistent_template" in result.errors[0] @pytest.mark.asyncio async def test_saves_to_disk_when_requested(self, tmp_path, monkeypatch): monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path) monkeypatch.setattr("timmy.research._DOCS_ROOT", tmp_path / "research") with ( patch("timmy.research._check_cache", return_value=(None, 0.0)), patch( "timmy.research._formulate_queries", new=AsyncMock(return_value=["q1"]), ), patch("timmy.research._execute_search", new=AsyncMock(return_value=[])), patch("timmy.research._fetch_pages", new=AsyncMock(return_value=[])), patch( "timmy.research._synthesize", new=AsyncMock(return_value=("# Saved Report", "ollama")), ), patch("timmy.research._store_result"), ): from timmy.research import run_research result = await run_research("disk topic", save_to_disk=True) assert result.report == "# Saved Report" saved_files = list((tmp_path / "research").glob("*.md")) assert len(saved_files) == 1 assert saved_files[0].read_text() == "# Saved Report" @pytest.mark.asyncio async def test_result_is_not_empty_after_synthesis(self, tmp_path, monkeypatch): monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path) with ( patch("timmy.research._check_cache", return_value=(None, 0.0)), patch( "timmy.research._formulate_queries", new=AsyncMock(return_value=["q"]), ), patch("timmy.research._execute_search", new=AsyncMock(return_value=[])), patch("timmy.research._fetch_pages", new=AsyncMock(return_value=[])), patch( "timmy.research._synthesize", new=AsyncMock(return_value=("# Non-empty", "ollama")), ), patch("timmy.research._store_result"), ): from timmy.research import run_research result = await run_research("topic") assert not result.is_empty() # --------------------------------------------------------------------------- # ResearchResult # --------------------------------------------------------------------------- class TestResearchResult: def test_is_empty_when_no_report(self): from timmy.research import ResearchResult r = ResearchResult(topic="t", query_count=0, sources_fetched=0, report="") assert r.is_empty() def test_is_not_empty_with_content(self): from timmy.research import ResearchResult r = ResearchResult(topic="t", query_count=1, sources_fetched=1, report="# Report") assert not r.is_empty() def test_default_cached_false(self): from timmy.research import ResearchResult r = ResearchResult(topic="t", query_count=0, sources_fetched=0, report="x") assert r.cached is False def test_errors_defaults_to_empty_list(self): from timmy.research import ResearchResult r = ResearchResult(topic="t", query_count=0, sources_fetched=0, report="x") assert r.errors == []