chore: import upstream snapshot with attribution
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:08:55 +08:00
commit 7a0da7932b
2985 changed files with 1049377 additions and 0 deletions
+334
View File
@@ -0,0 +1,334 @@
"""
Tests for citation_handler.py
Tests cover:
- CitationHandler initialization
- Handler type selection
- Method delegation
"""
from unittest.mock import Mock, patch
class TestCitationHandlerInit:
"""Tests for CitationHandler initialization."""
def test_default_handler_type(self):
"""Test default handler type is standard."""
mock_llm = Mock()
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_handler_class:
mock_handler = Mock()
mock_handler._create_documents = Mock()
mock_handler._format_sources = Mock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
handler = CitationHandler(mock_llm)
mock_handler_class.assert_called_once()
assert handler._handler == mock_handler
def test_explicit_standard_handler(self):
"""Test explicit standard handler type."""
mock_llm = Mock()
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_handler_class:
mock_handler = Mock()
mock_handler._create_documents = Mock()
mock_handler._format_sources = Mock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, handler_type="standard")
mock_handler_class.assert_called_once()
def test_forced_handler_type(self):
"""Test forced handler type."""
mock_llm = Mock()
with patch(
"local_deep_research.citation_handlers.forced_answer_citation_handler.ForcedAnswerCitationHandler"
) as mock_handler_class:
mock_handler = Mock()
mock_handler._create_documents = Mock()
mock_handler._format_sources = Mock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, handler_type="forced")
mock_handler_class.assert_called_once()
def test_browsecomp_handler_type(self):
"""Test browsecomp handler type maps to forced."""
mock_llm = Mock()
with patch(
"local_deep_research.citation_handlers.forced_answer_citation_handler.ForcedAnswerCitationHandler"
) as mock_handler_class:
mock_handler = Mock()
mock_handler._create_documents = Mock()
mock_handler._format_sources = Mock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, handler_type="browsecomp")
mock_handler_class.assert_called_once()
def test_precision_handler_type(self):
"""Test precision handler type."""
mock_llm = Mock()
with patch(
"local_deep_research.citation_handlers.precision_extraction_handler.PrecisionExtractionHandler"
) as mock_handler_class:
mock_handler = Mock()
mock_handler._create_documents = Mock()
mock_handler._format_sources = Mock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, handler_type="precision")
mock_handler_class.assert_called_once()
def test_simpleqa_handler_type(self):
"""Test simpleqa handler type maps to precision."""
mock_llm = Mock()
with patch(
"local_deep_research.citation_handlers.precision_extraction_handler.PrecisionExtractionHandler"
) as mock_handler_class:
mock_handler = Mock()
mock_handler._create_documents = Mock()
mock_handler._format_sources = Mock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, handler_type="simpleqa")
mock_handler_class.assert_called_once()
def test_unknown_handler_type_fallback(self):
"""Test unknown handler type falls back to standard."""
mock_llm = Mock()
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_handler_class:
mock_handler = Mock()
mock_handler._create_documents = Mock()
mock_handler._format_sources = Mock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, handler_type="unknown_type")
# Called twice - once for unknown fallback
assert mock_handler_class.call_count >= 1
def test_handler_type_from_settings_snapshot(self):
"""Test handler type from settings snapshot."""
mock_llm = Mock()
settings_snapshot = {"citation.handler_type": "forced"}
with patch(
"local_deep_research.citation_handlers.forced_answer_citation_handler.ForcedAnswerCitationHandler"
) as mock_handler_class:
mock_handler = Mock()
mock_handler._create_documents = Mock()
mock_handler._format_sources = Mock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, settings_snapshot=settings_snapshot)
mock_handler_class.assert_called_once()
def test_handler_type_from_settings_snapshot_dict_value(self):
"""Test handler type from settings snapshot with dict value."""
mock_llm = Mock()
settings_snapshot = {"citation.handler_type": {"value": "precision"}}
with patch(
"local_deep_research.citation_handlers.precision_extraction_handler.PrecisionExtractionHandler"
) as mock_handler_class:
mock_handler = Mock()
mock_handler._create_documents = Mock()
mock_handler._format_sources = Mock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, settings_snapshot=settings_snapshot)
mock_handler_class.assert_called_once()
def test_handler_type_case_insensitive(self):
"""Test handler type is case insensitive."""
mock_llm = Mock()
with patch(
"local_deep_research.citation_handlers.forced_answer_citation_handler.ForcedAnswerCitationHandler"
) as mock_handler_class:
mock_handler = Mock()
mock_handler._create_documents = Mock()
mock_handler._format_sources = Mock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, handler_type="FORCED")
mock_handler_class.assert_called_once()
class TestCitationHandlerMethods:
"""Tests for CitationHandler method delegation."""
def test_analyze_initial_delegation(self):
"""Test analyze_initial delegates to handler."""
mock_llm = Mock()
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_handler_class:
mock_handler = Mock()
mock_handler._create_documents = Mock()
mock_handler._format_sources = Mock()
mock_handler.analyze_initial.return_value = {"result": "test"}
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
handler = CitationHandler(mock_llm)
result = handler.analyze_initial("test query", [])
mock_handler.analyze_initial.assert_called_once_with(
"test query", []
)
assert result == {"result": "test"}
def test_analyze_followup_delegation(self):
"""Test analyze_followup delegates to handler."""
mock_llm = Mock()
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_handler_class:
mock_handler = Mock()
mock_handler._create_documents = Mock()
mock_handler._format_sources = Mock()
mock_handler.analyze_followup.return_value = {"result": "followup"}
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
handler = CitationHandler(mock_llm)
result = handler.analyze_followup(
"test question", [], "previous knowledge", 5
)
mock_handler.analyze_followup.assert_called_once_with(
"test question", [], "previous knowledge", 5
)
assert result == {"result": "followup"}
def test_backward_compatibility_methods_exposed(self):
"""Test backward compatibility methods are exposed."""
mock_llm = Mock()
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_handler_class:
mock_create_docs = Mock()
mock_format_sources = Mock()
mock_handler = Mock()
mock_handler._create_documents = mock_create_docs
mock_handler._format_sources = mock_format_sources
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
handler = CitationHandler(mock_llm)
assert handler._create_documents == mock_create_docs
assert handler._format_sources == mock_format_sources
class TestCitationHandlerSettings:
"""Tests for CitationHandler settings handling."""
def test_empty_settings_snapshot(self):
"""Test with empty settings snapshot."""
mock_llm = Mock()
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_handler_class:
mock_handler = Mock()
mock_handler._create_documents = Mock()
mock_handler._format_sources = Mock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, settings_snapshot={})
# Should use standard handler as default
mock_handler_class.assert_called_once()
def test_settings_passed_to_handler(self):
"""Test settings are passed to underlying handler."""
mock_llm = Mock()
settings_snapshot = {"some_setting": "value"}
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_handler_class:
mock_handler = Mock()
mock_handler._create_documents = Mock()
mock_handler._format_sources = Mock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, settings_snapshot=settings_snapshot)
call_kwargs = mock_handler_class.call_args[1]
assert call_kwargs["settings_snapshot"] == settings_snapshot
def test_llm_passed_to_handler(self):
"""Test LLM is passed to underlying handler."""
mock_llm = Mock()
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_handler_class:
mock_handler = Mock()
mock_handler._create_documents = Mock()
mock_handler._format_sources = Mock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm)
call_args = mock_handler_class.call_args[0]
assert call_args[0] == mock_llm
+211
View File
@@ -0,0 +1,211 @@
"""Tests for CitationHandler factory logic, settings extraction, and delegation."""
from unittest.mock import MagicMock, patch
import pytest
from local_deep_research.citation_handler import CitationHandler
# ---------------------------------------------------------------------------
# Patch targets -- the handler classes are lazily imported inside
# _create_handler(), so we patch them at their source modules.
# ---------------------------------------------------------------------------
STANDARD_PATH = (
"local_deep_research.citation_handlers."
"standard_citation_handler.StandardCitationHandler"
)
FORCED_PATH = (
"local_deep_research.citation_handlers."
"forced_answer_citation_handler.ForcedAnswerCitationHandler"
)
PRECISION_PATH = (
"local_deep_research.citation_handlers."
"precision_extraction_handler.PrecisionExtractionHandler"
)
@pytest.fixture
def mock_llm():
return MagicMock(name="mock_llm")
# ── Handler type selection ────────────────────────────────────────────────
class TestHandlerTypeSelection:
"""Verify that the correct handler implementation is instantiated."""
@patch(STANDARD_PATH)
def test_default_no_handler_type_no_settings(self, mock_cls, mock_llm):
"""No handler_type and no settings -> StandardCitationHandler."""
handler = CitationHandler(mock_llm)
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
assert handler._handler is mock_cls.return_value
@patch(STANDARD_PATH)
def test_handler_type_standard(self, mock_cls, mock_llm):
"""Explicit handler_type='standard' -> StandardCitationHandler."""
handler = CitationHandler(mock_llm, handler_type="standard")
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
assert handler._handler is mock_cls.return_value
@patch(FORCED_PATH)
def test_handler_type_forced(self, mock_cls, mock_llm):
"""handler_type='forced' -> ForcedAnswerCitationHandler."""
handler = CitationHandler(mock_llm, handler_type="forced")
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
assert handler._handler is mock_cls.return_value
@patch(FORCED_PATH)
def test_handler_type_forced_answer(self, mock_cls, mock_llm):
"""handler_type='forced_answer' -> ForcedAnswerCitationHandler."""
handler = CitationHandler(mock_llm, handler_type="forced_answer")
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
assert handler._handler is mock_cls.return_value
@patch(FORCED_PATH)
def test_handler_type_browsecomp(self, mock_cls, mock_llm):
"""handler_type='browsecomp' -> ForcedAnswerCitationHandler."""
handler = CitationHandler(mock_llm, handler_type="browsecomp")
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
assert handler._handler is mock_cls.return_value
@patch(PRECISION_PATH)
def test_handler_type_precision(self, mock_cls, mock_llm):
"""handler_type='precision' -> PrecisionExtractionHandler."""
handler = CitationHandler(mock_llm, handler_type="precision")
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
assert handler._handler is mock_cls.return_value
@patch(PRECISION_PATH)
def test_handler_type_precision_extraction(self, mock_cls, mock_llm):
"""handler_type='precision_extraction' -> PrecisionExtractionHandler."""
handler = CitationHandler(mock_llm, handler_type="precision_extraction")
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
assert handler._handler is mock_cls.return_value
@patch(PRECISION_PATH)
def test_handler_type_simpleqa(self, mock_cls, mock_llm):
"""handler_type='simpleqa' -> PrecisionExtractionHandler."""
handler = CitationHandler(mock_llm, handler_type="simpleqa")
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
assert handler._handler is mock_cls.return_value
@patch(STANDARD_PATH)
def test_unknown_handler_type_falls_back_to_standard(
self, mock_cls, mock_llm
):
"""Unknown handler_type -> StandardCitationHandler with a warning."""
handler = CitationHandler(mock_llm, handler_type="unknown_type")
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
assert handler._handler is mock_cls.return_value
@patch(STANDARD_PATH)
def test_handler_type_is_case_insensitive(self, mock_cls, mock_llm):
"""handler_type='STANDARD' (uppercase) -> StandardCitationHandler."""
handler = CitationHandler(mock_llm, handler_type="STANDARD")
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
assert handler._handler is mock_cls.return_value
# ── Settings snapshot extraction ──────────────────────────────────────────
class TestSettingsSnapshotExtraction:
"""Verify that handler_type is correctly extracted from settings_snapshot."""
@patch(FORCED_PATH)
def test_handler_type_from_settings_string_value(self, mock_cls, mock_llm):
"""settings_snapshot with plain string value for citation.handler_type."""
snapshot = {"citation.handler_type": "forced"}
handler = CitationHandler(mock_llm, settings_snapshot=snapshot)
mock_cls.assert_called_once_with(mock_llm, settings_snapshot=snapshot)
assert handler._handler is mock_cls.return_value
@patch(FORCED_PATH)
def test_handler_type_from_settings_dict_with_value_key(
self, mock_cls, mock_llm
):
"""settings_snapshot with dict {'value': 'forced'} structure."""
snapshot = {"citation.handler_type": {"value": "forced"}}
handler = CitationHandler(mock_llm, settings_snapshot=snapshot)
mock_cls.assert_called_once_with(mock_llm, settings_snapshot=snapshot)
assert handler._handler is mock_cls.return_value
@patch(PRECISION_PATH)
def test_explicit_handler_type_overrides_settings(self, mock_cls, mock_llm):
"""Explicit handler_type takes precedence over settings_snapshot."""
snapshot = {"citation.handler_type": "standard"}
handler = CitationHandler(
mock_llm, handler_type="precision", settings_snapshot=snapshot
)
mock_cls.assert_called_once_with(mock_llm, settings_snapshot=snapshot)
assert handler._handler is mock_cls.return_value
@patch(STANDARD_PATH)
def test_settings_snapshot_none_defaults_to_empty_dict(
self, mock_cls, mock_llm
):
"""settings_snapshot=None is treated as {}."""
handler = CitationHandler(mock_llm, settings_snapshot=None)
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
assert handler.settings_snapshot == {}
# ── Delegation ────────────────────────────────────────────────────────────
class TestDelegation:
"""Verify that public methods and attributes delegate to the inner handler."""
@patch(STANDARD_PATH)
def test_analyze_initial_delegates(self, mock_cls, mock_llm):
"""analyze_initial forwards to _handler.analyze_initial."""
mock_inner = mock_cls.return_value
mock_inner.analyze_initial.return_value = {"result": "initial"}
handler = CitationHandler(mock_llm)
result = handler.analyze_initial(
"test query", [{"url": "http://example.com"}]
)
mock_inner.analyze_initial.assert_called_once_with(
"test query", [{"url": "http://example.com"}]
)
assert result == {"result": "initial"}
@patch(STANDARD_PATH)
def test_analyze_followup_delegates_with_all_args(self, mock_cls, mock_llm):
"""analyze_followup forwards all arguments to _handler.analyze_followup."""
mock_inner = mock_cls.return_value
mock_inner.analyze_followup.return_value = {"result": "followup"}
handler = CitationHandler(mock_llm)
result = handler.analyze_followup(
"follow-up question",
[{"url": "http://example.com"}],
"previous knowledge text",
5,
)
mock_inner.analyze_followup.assert_called_once_with(
"follow-up question",
[{"url": "http://example.com"}],
"previous knowledge text",
5,
)
assert result == {"result": "followup"}
@patch(STANDARD_PATH)
def test_create_documents_exposed_from_handler(self, mock_cls, mock_llm):
"""_create_documents is exposed from the inner handler."""
mock_inner = mock_cls.return_value
handler = CitationHandler(mock_llm)
assert handler._create_documents is mock_inner._create_documents
@patch(STANDARD_PATH)
def test_format_sources_exposed_from_handler(self, mock_cls, mock_llm):
"""_format_sources is exposed from the inner handler."""
mock_inner = mock_cls.return_value
handler = CitationHandler(mock_llm)
assert handler._format_sources is mock_inner._format_sources
@@ -0,0 +1,447 @@
"""
Tests for citation_handler.py - Strategy Selection and Handler Delegation
Tests cover:
- Handler instantiation based on type
- Alias mappings (browsecomp -> forced, simpleqa -> precision)
- Fallback behavior for unknown types
- Proper delegation to underlying handlers
These tests ensure the correct citation handler is selected for different use cases.
"""
from unittest.mock import MagicMock, patch
class TestHandlerInstantiation:
"""Tests for handler instantiation based on type."""
def test_standard_handler_creates(self):
"""'standard' creates StandardCitationHandler."""
mock_llm = MagicMock()
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_handler_class:
mock_handler = MagicMock()
mock_handler._create_documents = MagicMock()
mock_handler._format_sources = MagicMock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
handler = CitationHandler(mock_llm, handler_type="standard")
mock_handler_class.assert_called_once()
assert handler._handler == mock_handler
def test_forced_handler_creates(self):
"""'forced' creates ForcedAnswerCitationHandler."""
mock_llm = MagicMock()
with patch(
"local_deep_research.citation_handlers.forced_answer_citation_handler.ForcedAnswerCitationHandler"
) as mock_handler_class:
mock_handler = MagicMock()
mock_handler._create_documents = MagicMock()
mock_handler._format_sources = MagicMock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
handler = CitationHandler(mock_llm, handler_type="forced")
mock_handler_class.assert_called_once()
assert handler._handler == mock_handler
def test_precision_handler_creates(self):
"""'precision' creates PrecisionExtractionHandler."""
mock_llm = MagicMock()
with patch(
"local_deep_research.citation_handlers.precision_extraction_handler.PrecisionExtractionHandler"
) as mock_handler_class:
mock_handler = MagicMock()
mock_handler._create_documents = MagicMock()
mock_handler._format_sources = MagicMock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
handler = CitationHandler(mock_llm, handler_type="precision")
mock_handler_class.assert_called_once()
assert handler._handler == mock_handler
def test_browsecomp_alias(self):
"""'browsecomp' maps to ForcedAnswerCitationHandler."""
mock_llm = MagicMock()
with patch(
"local_deep_research.citation_handlers.forced_answer_citation_handler.ForcedAnswerCitationHandler"
) as mock_handler_class:
mock_handler = MagicMock()
mock_handler._create_documents = MagicMock()
mock_handler._format_sources = MagicMock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, handler_type="browsecomp")
mock_handler_class.assert_called_once()
def test_simpleqa_alias(self):
"""'simpleqa' maps to PrecisionExtractionHandler."""
mock_llm = MagicMock()
with patch(
"local_deep_research.citation_handlers.precision_extraction_handler.PrecisionExtractionHandler"
) as mock_handler_class:
mock_handler = MagicMock()
mock_handler._create_documents = MagicMock()
mock_handler._format_sources = MagicMock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, handler_type="simpleqa")
mock_handler_class.assert_called_once()
def test_unknown_handler_fallback(self):
"""Unknown type falls back to standard."""
mock_llm = MagicMock()
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_handler_class:
mock_handler = MagicMock()
mock_handler._create_documents = MagicMock()
mock_handler._format_sources = MagicMock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, handler_type="completely_unknown_type")
# Should fall back to standard handler
assert mock_handler_class.call_count >= 1
def test_handler_type_case_insensitive(self):
"""'STANDARD', 'Standard' work."""
mock_llm = MagicMock()
# Test uppercase
with patch(
"local_deep_research.citation_handlers.forced_answer_citation_handler.ForcedAnswerCitationHandler"
) as mock_handler_class:
mock_handler = MagicMock()
mock_handler._create_documents = MagicMock()
mock_handler._format_sources = MagicMock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, handler_type="FORCED")
mock_handler_class.assert_called_once()
# Test mixed case
with patch(
"local_deep_research.citation_handlers.precision_extraction_handler.PrecisionExtractionHandler"
) as mock_handler_class:
mock_handler = MagicMock()
mock_handler._create_documents = MagicMock()
mock_handler._format_sources = MagicMock()
mock_handler_class.return_value = mock_handler
CitationHandler(mock_llm, handler_type="Precision")
mock_handler_class.assert_called_once()
class TestHandlerDelegation:
"""Tests for method delegation to underlying handlers."""
def test_analyze_initial_string_input(self):
"""String search_results handled."""
mock_llm = MagicMock()
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_handler_class:
mock_handler = MagicMock()
mock_handler._create_documents = MagicMock()
mock_handler._format_sources = MagicMock()
mock_handler.analyze_initial.return_value = {"answer": "test"}
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
handler = CitationHandler(mock_llm)
# Call with string input
result = handler.analyze_initial(
"test query", "string search results"
)
mock_handler.analyze_initial.assert_called_once_with(
"test query", "string search results"
)
assert result == {"answer": "test"}
def test_analyze_initial_list_input(self):
"""List of dicts handled."""
mock_llm = MagicMock()
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_handler_class:
mock_handler = MagicMock()
mock_handler._create_documents = MagicMock()
mock_handler._format_sources = MagicMock()
mock_handler.analyze_initial.return_value = {
"answer": "list result"
}
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
handler = CitationHandler(mock_llm)
search_results = [
{"title": "Result 1", "link": "http://example.com"},
{"title": "Result 2", "link": "http://example2.com"},
]
handler.analyze_initial("test query", search_results)
mock_handler.analyze_initial.assert_called_once_with(
"test query", search_results
)
def test_analyze_followup_params_passed(self):
"""All params passed through."""
mock_llm = MagicMock()
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_handler_class:
mock_handler = MagicMock()
mock_handler._create_documents = MagicMock()
mock_handler._format_sources = MagicMock()
mock_handler.analyze_followup.return_value = {"followup": "result"}
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
handler = CitationHandler(mock_llm)
handler.analyze_followup(
"followup question",
[{"title": "Result", "link": "http://example.com"}],
"previous knowledge text",
5,
)
mock_handler.analyze_followup.assert_called_once_with(
"followup question",
[{"title": "Result", "link": "http://example.com"}],
"previous knowledge text",
5,
)
def test_handler_receives_settings_snapshot(self):
"""Settings propagated to handler."""
mock_llm = MagicMock()
settings = {
"some_setting": "value",
"another_setting": {"nested": True},
}
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_handler_class:
mock_handler = MagicMock()
mock_handler._create_documents = MagicMock()
mock_handler._format_sources = MagicMock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, settings_snapshot=settings)
# Check that settings were passed to handler
call_kwargs = mock_handler_class.call_args[1]
assert call_kwargs["settings_snapshot"] == settings
def test_handler_llm_instance_passed(self):
"""LLM instance correctly passed."""
mock_llm = MagicMock()
mock_llm.model_name = "test-model"
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_handler_class:
mock_handler = MagicMock()
mock_handler._create_documents = MagicMock()
mock_handler._format_sources = MagicMock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm)
# LLM should be passed as first positional arg
call_args = mock_handler_class.call_args[0]
assert call_args[0] == mock_llm
class TestHandlerTypeAliases:
"""Tests for all handler type aliases."""
def test_forced_answer_alias(self):
"""'forced_answer' works."""
mock_llm = MagicMock()
with patch(
"local_deep_research.citation_handlers.forced_answer_citation_handler.ForcedAnswerCitationHandler"
) as mock_handler_class:
mock_handler = MagicMock()
mock_handler._create_documents = MagicMock()
mock_handler._format_sources = MagicMock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, handler_type="forced_answer")
mock_handler_class.assert_called_once()
def test_precision_extraction_alias(self):
"""'precision_extraction' works."""
mock_llm = MagicMock()
with patch(
"local_deep_research.citation_handlers.precision_extraction_handler.PrecisionExtractionHandler"
) as mock_handler_class:
mock_handler = MagicMock()
mock_handler._create_documents = MagicMock()
mock_handler._format_sources = MagicMock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, handler_type="precision_extraction")
mock_handler_class.assert_called_once()
class TestBackwardCompatibility:
"""Tests for backward compatibility."""
def test_internal_methods_exposed(self):
"""_create_documents and _format_sources exposed on handler."""
mock_llm = MagicMock()
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_handler_class:
mock_create_docs = MagicMock()
mock_format_sources = MagicMock()
mock_handler = MagicMock()
mock_handler._create_documents = mock_create_docs
mock_handler._format_sources = mock_format_sources
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
handler = CitationHandler(mock_llm)
# These should be exposed for backward compatibility
assert handler._create_documents == mock_create_docs
assert handler._format_sources == mock_format_sources
def test_default_handler_without_type(self):
"""No handler_type defaults to standard."""
mock_llm = MagicMock()
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_handler_class:
mock_handler = MagicMock()
mock_handler._create_documents = MagicMock()
mock_handler._format_sources = MagicMock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm) # No handler_type specified
mock_handler_class.assert_called_once()
class TestSettingsSnapshotHandlerType:
"""Tests for handler type from settings snapshot."""
def test_handler_from_settings_direct_value(self):
"""Handler type from settings as direct value."""
mock_llm = MagicMock()
settings = {"citation.handler_type": "forced"}
with patch(
"local_deep_research.citation_handlers.forced_answer_citation_handler.ForcedAnswerCitationHandler"
) as mock_handler_class:
mock_handler = MagicMock()
mock_handler._create_documents = MagicMock()
mock_handler._format_sources = MagicMock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, settings_snapshot=settings)
mock_handler_class.assert_called_once()
def test_handler_from_settings_dict_value(self):
"""Handler type from settings as dict with value key."""
mock_llm = MagicMock()
settings = {"citation.handler_type": {"value": "precision"}}
with patch(
"local_deep_research.citation_handlers.precision_extraction_handler.PrecisionExtractionHandler"
) as mock_handler_class:
mock_handler = MagicMock()
mock_handler._create_documents = MagicMock()
mock_handler._format_sources = MagicMock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
CitationHandler(mock_llm, settings_snapshot=settings)
mock_handler_class.assert_called_once()
def test_explicit_type_overrides_settings(self):
"""Explicit handler_type overrides settings snapshot."""
mock_llm = MagicMock()
settings = {"citation.handler_type": "forced"}
with patch(
"local_deep_research.citation_handlers.precision_extraction_handler.PrecisionExtractionHandler"
) as mock_handler_class:
mock_handler = MagicMock()
mock_handler._create_documents = MagicMock()
mock_handler._format_sources = MagicMock()
mock_handler_class.return_value = mock_handler
from local_deep_research.citation_handler import CitationHandler
# Explicit type should override settings
CitationHandler(
mock_llm, handler_type="precision", settings_snapshot=settings
)
mock_handler_class.assert_called_once()
+220
View File
@@ -0,0 +1,220 @@
"""
Tests for constants.py
Tests cover:
- USER_AGENT constant
- BROWSER_USER_AGENT constant
- ResearchStatus StrEnum
- Rate limiting constants
- Snippet length constants
"""
from enum import StrEnum
class TestUserAgent:
"""Tests for USER_AGENT constant."""
def test_user_agent_exists(self):
"""Test USER_AGENT constant exists."""
from local_deep_research.constants import USER_AGENT
assert USER_AGENT is not None
assert isinstance(USER_AGENT, str)
def test_user_agent_contains_project_name(self):
"""Test USER_AGENT contains project name."""
from local_deep_research.constants import USER_AGENT
assert "Local-Deep-Research" in USER_AGENT
def test_user_agent_contains_version(self):
"""Test USER_AGENT contains version."""
from local_deep_research.constants import USER_AGENT
from local_deep_research.__version__ import __version__
assert __version__ in USER_AGENT
def test_user_agent_contains_github_url(self):
"""Test USER_AGENT contains GitHub URL."""
from local_deep_research.constants import USER_AGENT
assert "github.com/LearningCircuit/local-deep-research" in USER_AGENT
def test_user_agent_contains_description(self):
"""Test USER_AGENT contains description."""
from local_deep_research.constants import USER_AGENT
assert "Academic Research Tool" in USER_AGENT
class TestBrowserUserAgent:
"""Tests for BROWSER_USER_AGENT constant."""
def test_browser_user_agent_exists(self):
"""Test BROWSER_USER_AGENT constant exists."""
from local_deep_research.constants import BROWSER_USER_AGENT
assert BROWSER_USER_AGENT is not None
assert isinstance(BROWSER_USER_AGENT, str)
def test_browser_user_agent_looks_like_browser(self):
"""Test BROWSER_USER_AGENT looks like a browser."""
from local_deep_research.constants import BROWSER_USER_AGENT
assert "Mozilla" in BROWSER_USER_AGENT
assert "AppleWebKit" in BROWSER_USER_AGENT
def test_browser_user_agent_contains_chrome(self):
"""Test BROWSER_USER_AGENT contains Chrome."""
from local_deep_research.constants import BROWSER_USER_AGENT
assert "Chrome" in BROWSER_USER_AGENT
def test_browser_user_agent_contains_windows(self):
"""Test BROWSER_USER_AGENT contains Windows."""
from local_deep_research.constants import BROWSER_USER_AGENT
assert "Windows" in BROWSER_USER_AGENT
class TestVersionImport:
"""Tests for version import."""
def test_version_exists(self):
"""Test __version__ exists."""
from local_deep_research.__version__ import __version__
assert __version__ is not None
assert isinstance(__version__, str)
def test_version_not_empty(self):
"""Test __version__ is not empty."""
from local_deep_research.__version__ import __version__
assert len(__version__) > 0
class TestResearchStatus:
"""Tests for ResearchStatus StrEnum."""
def test_research_status_is_strenum(self):
"""ResearchStatus is a StrEnum."""
from local_deep_research.constants import ResearchStatus
assert issubclass(ResearchStatus, StrEnum)
def test_completed_status(self):
"""COMPLETED is 'completed'."""
from local_deep_research.constants import ResearchStatus
assert ResearchStatus.COMPLETED == "completed"
def test_suspended_status(self):
"""SUSPENDED is 'suspended'."""
from local_deep_research.constants import ResearchStatus
assert ResearchStatus.SUSPENDED == "suspended"
def test_failed_status(self):
"""FAILED is 'failed'."""
from local_deep_research.constants import ResearchStatus
assert ResearchStatus.FAILED == "failed"
def test_in_progress_status(self):
"""IN_PROGRESS is 'in_progress'."""
from local_deep_research.constants import ResearchStatus
assert ResearchStatus.IN_PROGRESS == "in_progress"
def test_pending_status(self):
"""PENDING is 'pending'."""
from local_deep_research.constants import ResearchStatus
assert ResearchStatus.PENDING == "pending"
def test_error_status(self):
"""ERROR is 'error'."""
from local_deep_research.constants import ResearchStatus
assert ResearchStatus.ERROR == "error"
def test_queued_status(self):
"""QUEUED is 'queued'."""
from local_deep_research.constants import ResearchStatus
assert ResearchStatus.QUEUED == "queued"
def test_cancelled_status(self):
"""CANCELLED is 'cancelled'."""
from local_deep_research.constants import ResearchStatus
assert ResearchStatus.CANCELLED == "cancelled"
def test_string_comparison(self):
"""StrEnum values compare equal to plain strings."""
from local_deep_research.constants import ResearchStatus
assert ResearchStatus.COMPLETED == "completed"
assert "completed" == ResearchStatus.COMPLETED
assert ResearchStatus.IN_PROGRESS != "completed"
def test_membership_check(self):
"""StrEnum supports 'in' membership testing."""
from local_deep_research.constants import ResearchStatus
assert "completed" in ResearchStatus
assert "in_progress" in ResearchStatus
assert "nonexistent" not in ResearchStatus
def test_all_statuses_are_strings(self):
"""All status values are strings."""
from local_deep_research.constants import ResearchStatus
for member in ResearchStatus:
assert isinstance(member, str)
assert isinstance(member.value, str)
def test_all_statuses_are_unique(self):
"""All status values are distinct."""
from local_deep_research.constants import ResearchStatus
values = [member.value for member in ResearchStatus]
assert len(values) == len(set(values))
def test_importable_from_database_models(self):
"""ResearchStatus is importable from database.models (re-export)."""
from local_deep_research.constants import ResearchStatus as RS1
from local_deep_research.database.models.research import (
ResearchStatus as RS2,
)
assert RS1 is RS2
class TestSnippetLengthConstants:
"""Tests for snippet length constants."""
def test_snippet_length_short(self):
"""SNIPPET_LENGTH_SHORT is a positive integer."""
from local_deep_research.constants import SNIPPET_LENGTH_SHORT
assert isinstance(SNIPPET_LENGTH_SHORT, int)
assert SNIPPET_LENGTH_SHORT > 0
def test_snippet_length_long(self):
"""SNIPPET_LENGTH_LONG is a positive integer."""
from local_deep_research.constants import SNIPPET_LENGTH_LONG
assert isinstance(SNIPPET_LENGTH_LONG, int)
assert SNIPPET_LENGTH_LONG > 0
def test_long_greater_than_short(self):
"""SNIPPET_LENGTH_LONG > SNIPPET_LENGTH_SHORT."""
from local_deep_research.constants import (
SNIPPET_LENGTH_LONG,
SNIPPET_LENGTH_SHORT,
)
assert SNIPPET_LENGTH_LONG > SNIPPET_LENGTH_SHORT
+501
View File
@@ -0,0 +1,501 @@
"""
Tests for report_generator.py
Tests cover:
- IntegratedReportGenerator initialization
- get_report_generator function
- Report structure determination
- Section generation
- Error handling
"""
from unittest.mock import Mock, patch
class TestGetReportGenerator:
"""Tests for get_report_generator function."""
def test_get_report_generator_default(self):
"""Test get_report_generator returns IntegratedReportGenerator."""
with patch(
"local_deep_research.report_generator.IntegratedReportGenerator"
) as mock_class:
mock_instance = Mock()
mock_class.return_value = mock_instance
from local_deep_research.report_generator import (
get_report_generator,
)
result = get_report_generator()
mock_class.assert_called_once_with(search_system=None)
assert result == mock_instance
def test_get_report_generator_with_search_system(self):
"""Test get_report_generator with search system."""
mock_search_system = Mock()
with patch(
"local_deep_research.report_generator.IntegratedReportGenerator"
) as mock_class:
mock_instance = Mock()
mock_class.return_value = mock_instance
from local_deep_research.report_generator import (
get_report_generator,
)
get_report_generator(search_system=mock_search_system)
mock_class.assert_called_once_with(search_system=mock_search_system)
class TestIntegratedReportGeneratorInit:
"""Tests for IntegratedReportGenerator initialization."""
def test_init_with_search_system(self):
"""Test initialization with search system."""
mock_search_system = Mock()
mock_search_system.model = Mock()
from local_deep_research.report_generator import (
IntegratedReportGenerator,
)
generator = IntegratedReportGenerator(search_system=mock_search_system)
assert generator.search_system == mock_search_system
assert generator.model == mock_search_system.model
assert generator.searches_per_section == 2
def test_init_with_llm(self):
"""Test initialization with LLM only."""
mock_llm = Mock()
with patch(
"local_deep_research.report_generator.AdvancedSearchSystem"
) as mock_search_class:
mock_search = Mock()
mock_search_class.return_value = mock_search
from local_deep_research.report_generator import (
IntegratedReportGenerator,
)
generator = IntegratedReportGenerator(llm=mock_llm)
assert generator.model == mock_llm
mock_search_class.assert_called_once_with(llm=mock_llm)
def test_init_with_custom_searches_per_section(self):
"""Test initialization with custom searches per section."""
mock_search_system = Mock()
mock_search_system.model = Mock()
from local_deep_research.report_generator import (
IntegratedReportGenerator,
)
generator = IntegratedReportGenerator(
search_system=mock_search_system, searches_per_section=5
)
assert generator.searches_per_section == 5
def test_init_search_system_overrides_llm(self):
"""Test search system's LLM is used when both provided."""
mock_search_system = Mock()
mock_search_system.model = Mock(name="search_system_model")
mock_external_llm = Mock(name="external_llm")
from local_deep_research.report_generator import (
IntegratedReportGenerator,
)
generator = IntegratedReportGenerator(
search_system=mock_search_system, llm=mock_external_llm
)
# When both provided, external llm is used if specified
assert generator.model == mock_external_llm
class TestDetermineReportStructure:
"""Tests for _determine_report_structure method."""
def test_determine_structure_parses_response(self):
"""Test structure is parsed from LLM response."""
mock_search_system = Mock()
mock_llm = Mock()
mock_search_system.model = mock_llm
mock_response = Mock()
mock_response.content = """
STRUCTURE
1. Introduction
- Overview | Provide context
- Background | Historical context
2. Main Analysis
- Data | Present findings
END_STRUCTURE
"""
mock_llm.invoke.return_value = mock_response
from local_deep_research.report_generator import (
IntegratedReportGenerator,
)
generator = IntegratedReportGenerator(search_system=mock_search_system)
findings = {"current_knowledge": "Test content " * 100}
structure = generator._determine_report_structure(
findings, "test query"
)
assert len(structure) == 2
assert structure[0]["name"] == "Introduction"
assert len(structure[0]["subsections"]) == 2
assert structure[0]["subsections"][0]["name"] == "Overview"
assert structure[0]["subsections"][0]["purpose"] == "Provide context"
def test_determine_structure_removes_source_sections(self):
"""Test source-related sections are removed."""
mock_search_system = Mock()
mock_llm = Mock()
mock_search_system.model = mock_llm
mock_response = Mock()
mock_response.content = """
STRUCTURE
1. Introduction
- Overview | Provide context
2. Sources and References
- Citations | List sources
END_STRUCTURE
"""
mock_llm.invoke.return_value = mock_response
from local_deep_research.report_generator import (
IntegratedReportGenerator,
)
generator = IntegratedReportGenerator(search_system=mock_search_system)
findings = {"current_knowledge": "Test content " * 100}
structure = generator._determine_report_structure(
findings, "test query"
)
# Should remove the Sources section
assert len(structure) == 1
assert structure[0]["name"] == "Introduction"
def test_determine_structure_handles_subsection_without_purpose(self):
"""Test subsections without purpose get default."""
mock_search_system = Mock()
mock_llm = Mock()
mock_search_system.model = mock_llm
mock_response = Mock()
mock_response.content = """
STRUCTURE
1. Introduction
- Overview
END_STRUCTURE
"""
mock_llm.invoke.return_value = mock_response
from local_deep_research.report_generator import (
IntegratedReportGenerator,
)
generator = IntegratedReportGenerator(search_system=mock_search_system)
findings = {"current_knowledge": "Test content " * 100}
structure = generator._determine_report_structure(
findings, "test query"
)
assert structure[0]["subsections"][0]["name"] == "Overview"
assert "Overview" in structure[0]["subsections"][0]["purpose"]
class TestResearchAndGenerateSections:
"""Tests for _research_and_generate_sections method."""
def test_research_sections_basic(self):
"""Test basic section research and generation."""
mock_search_system = Mock()
mock_llm = Mock()
mock_search_system.model = mock_llm
mock_search_system.max_iterations = 3
mock_search_system.strategy.settings_snapshot = {"search.iterations": 3}
mock_search_system.strategy.max_iterations = 3
mock_search_system.analyze_topic.return_value = {
"current_knowledge": "Section content here"
}
from local_deep_research.report_generator import (
IntegratedReportGenerator,
)
generator = IntegratedReportGenerator(search_system=mock_search_system)
structure = [
{
"name": "Introduction",
"subsections": [
{"name": "Overview", "purpose": "Provide overview"}
],
}
]
initial_findings = {"questions_by_iteration": {}}
sections = generator._research_and_generate_sections(
initial_findings, structure, "test query"
)
assert "Introduction" in sections
assert "Section content here" in sections["Introduction"]
def test_research_sections_preserves_questions(self):
"""Test that questions from initial research are preserved."""
mock_search_system = Mock()
mock_llm = Mock()
mock_search_system.model = mock_llm
mock_search_system.max_iterations = 3
mock_search_system.analyze_topic.return_value = {
"current_knowledge": "Content"
}
mock_search_system.questions_by_iteration = {}
mock_strategy = Mock()
mock_strategy.questions_by_iteration = {}
mock_strategy.settings_snapshot = {"search.iterations": 3}
mock_strategy.max_iterations = 3
mock_search_system.strategy = mock_strategy
from local_deep_research.report_generator import (
IntegratedReportGenerator,
)
generator = IntegratedReportGenerator(search_system=mock_search_system)
initial_findings = {"questions_by_iteration": {"0": ["Q1", "Q2"]}}
structure = [
{
"name": "Section",
"subsections": [{"name": "Sub", "purpose": "Test"}],
}
]
generator._research_and_generate_sections(
initial_findings, structure, "test query"
)
# Questions should be set on strategy
assert mock_strategy.questions_by_iteration == {"0": ["Q1", "Q2"]}
def test_research_sections_creates_subsection_for_empty(self):
"""Test subsection is created if none provided."""
mock_search_system = Mock()
mock_llm = Mock()
mock_search_system.model = mock_llm
mock_search_system.max_iterations = 3
mock_search_system.strategy.settings_snapshot = {"search.iterations": 3}
mock_search_system.strategy.max_iterations = 3
mock_search_system.analyze_topic.return_value = {
"current_knowledge": "Content"
}
from local_deep_research.report_generator import (
IntegratedReportGenerator,
)
generator = IntegratedReportGenerator(search_system=mock_search_system)
structure = [
{
"name": "Introduction",
"subsections": [], # Empty!
}
]
initial_findings = {}
generator._research_and_generate_sections(
initial_findings, structure, "test query"
)
# analyze_topic should still be called
mock_search_system.analyze_topic.assert_called()
class TestFormatFinalReport:
"""Tests for _format_final_report method."""
def test_format_final_report_structure(self):
"""Test final report has correct structure."""
mock_search_system = Mock()
mock_llm = Mock()
mock_search_system.model = mock_llm
mock_search_system.all_links_of_system = []
from local_deep_research.report_generator import (
IntegratedReportGenerator,
)
generator = IntegratedReportGenerator(search_system=mock_search_system)
sections = {"Introduction": "# Introduction\nContent here"}
structure = [
{
"name": "Introduction",
"subsections": [
{"name": "Overview", "purpose": "Provide overview"}
],
}
]
with patch(
"local_deep_research.report_generator.importlib.import_module"
) as mock_import:
mock_utilities = Mock()
mock_utilities.search_utilities.format_links_to_markdown.return_value = "- [Link](url)"
mock_import.return_value = mock_utilities
result = generator._format_final_report(
sections, structure, "test query"
)
assert "content" in result
assert "metadata" in result
assert "Table of Contents" in result["content"]
assert "Introduction" in result["content"]
assert "Sources" in result["content"]
def test_format_final_report_metadata(self):
"""Test final report metadata."""
mock_search_system = Mock()
mock_llm = Mock()
mock_search_system.model = mock_llm
mock_search_system.all_links_of_system = [
{"link": "url1"},
{"link": "url2"},
]
from local_deep_research.report_generator import (
IntegratedReportGenerator,
)
generator = IntegratedReportGenerator(search_system=mock_search_system)
sections = {"Section1": "Content"}
structure = [{"name": "Section1", "subsections": []}]
with patch(
"local_deep_research.report_generator.importlib.import_module"
) as mock_import:
mock_utilities = Mock()
mock_utilities.search_utilities.format_links_to_markdown.return_value = ""
mock_import.return_value = mock_utilities
result = generator._format_final_report(
sections, structure, "test query"
)
assert result["metadata"]["initial_sources"] == 2
assert result["metadata"]["sections_researched"] == 1
assert result["metadata"]["query"] == "test query"
assert "generated_at" in result["metadata"]
class TestGenerateReport:
"""Tests for generate_report method."""
def test_generate_report_full_flow(self):
"""Test full report generation flow."""
mock_search_system = Mock()
mock_llm = Mock()
mock_search_system.model = mock_llm
mock_search_system.max_iterations = 3
mock_search_system.strategy.settings_snapshot = {"search.iterations": 3}
mock_search_system.strategy.max_iterations = 3
mock_search_system.all_links_of_system = []
# Mock LLM response for structure
mock_response = Mock()
mock_response.content = """
STRUCTURE
1. Introduction
- Overview | Provide overview
END_STRUCTURE
"""
mock_llm.invoke.return_value = mock_response
# Mock search results
mock_search_system.analyze_topic.return_value = {
"current_knowledge": "Test content"
}
from local_deep_research.report_generator import (
IntegratedReportGenerator,
)
generator = IntegratedReportGenerator(search_system=mock_search_system)
initial_findings = {"current_knowledge": "Initial research"}
with patch(
"local_deep_research.report_generator.importlib.import_module"
) as mock_import:
mock_utilities = Mock()
mock_utilities.search_utilities.format_links_to_markdown.return_value = ""
mock_import.return_value = mock_utilities
result = generator.generate_report(initial_findings, "test query")
assert "content" in result
assert "metadata" in result
class TestGenerateErrorReport:
"""Tests for _generate_error_report method."""
def test_generate_error_report(self):
"""Test error report generation."""
mock_search_system = Mock()
mock_search_system.model = Mock()
from local_deep_research.report_generator import (
IntegratedReportGenerator,
)
generator = IntegratedReportGenerator(search_system=mock_search_system)
result = generator._generate_error_report(
"test query", "Something went wrong"
)
assert "ERROR REPORT" in result
assert "test query" in result
assert "Something went wrong" in result
class TestGenerateSectionsDeprecated:
"""Tests for deprecated _generate_sections method."""
def test_generate_sections_returns_empty(self):
"""Test deprecated _generate_sections returns empty dict."""
mock_search_system = Mock()
mock_search_system.model = Mock()
from local_deep_research.report_generator import (
IntegratedReportGenerator,
)
generator = IntegratedReportGenerator(search_system=mock_search_system)
result = generator._generate_sections({}, {}, [], "query")
assert result == {}
+220
View File
@@ -0,0 +1,220 @@
"""Tests for IntegratedReportGenerator.__init__ and get_report_generator factory."""
from unittest.mock import MagicMock, patch
import pytest
from local_deep_research.report_generator import (
IntegratedReportGenerator,
get_report_generator,
)
MODULE = "local_deep_research.report_generator"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def mock_get_llm():
with patch(f"{MODULE}.get_llm") as m:
m.return_value = MagicMock(name="default_llm")
yield m
@pytest.fixture()
def mock_advanced_search_system():
with patch(f"{MODULE}.AdvancedSearchSystem") as m:
m.return_value = MagicMock(name="constructed_search_system")
yield m
@pytest.fixture()
def mock_get_setting():
with patch(f"{MODULE}.get_setting_from_snapshot") as m:
# By default, return the default value passed to it
m.side_effect = lambda key, default=None, settings_snapshot=None: (
default
)
yield m
# ---------------------------------------------------------------------------
# get_report_generator factory
# ---------------------------------------------------------------------------
class TestGetReportGenerator:
"""Tests for the get_report_generator factory function."""
def test_returns_integrated_report_generator_instance(
self, mock_get_llm, mock_advanced_search_system, mock_get_setting
):
result = get_report_generator()
assert isinstance(result, IntegratedReportGenerator)
def test_passes_search_system_to_constructor(self, mock_get_setting):
search_system = MagicMock(name="my_search_system")
search_system.model = MagicMock(name="ss_model")
result = get_report_generator(search_system=search_system)
assert result.search_system is search_system
# ---------------------------------------------------------------------------
# __init__ with search_system provided
# ---------------------------------------------------------------------------
class TestInitWithSearchSystem:
"""Tests for __init__ when search_system is provided."""
def test_uses_provided_search_system(self, mock_get_setting):
search_system = MagicMock(name="my_search_system")
search_system.model = MagicMock(name="ss_model")
gen = IntegratedReportGenerator(search_system=search_system)
assert gen.search_system is search_system
def test_uses_search_system_model_when_no_llm(self, mock_get_setting):
search_system = MagicMock(name="my_search_system")
ss_model = MagicMock(name="ss_model")
search_system.model = ss_model
gen = IntegratedReportGenerator(search_system=search_system)
assert gen.model is ss_model
def test_uses_provided_llm_over_search_system_model(self, mock_get_setting):
search_system = MagicMock(name="my_search_system")
search_system.model = MagicMock(name="ss_model")
custom_llm = MagicMock(name="custom_llm")
gen = IntegratedReportGenerator(
search_system=search_system, llm=custom_llm
)
assert gen.model is custom_llm
# ---------------------------------------------------------------------------
# __init__ with llm only (no search_system)
# ---------------------------------------------------------------------------
class TestInitWithLlmOnly:
"""Tests for __init__ when only llm is provided."""
def test_creates_advanced_search_system_with_llm(
self, mock_advanced_search_system, mock_get_setting
):
custom_llm = MagicMock(name="custom_llm")
IntegratedReportGenerator(llm=custom_llm)
mock_advanced_search_system.assert_called_once_with(llm=custom_llm)
def test_sets_model_to_provided_llm(
self, mock_advanced_search_system, mock_get_setting
):
custom_llm = MagicMock(name="custom_llm")
gen = IntegratedReportGenerator(llm=custom_llm)
assert gen.model is custom_llm
# ---------------------------------------------------------------------------
# __init__ with neither search_system nor llm
# ---------------------------------------------------------------------------
class TestInitWithNeither:
"""Tests for __init__ when neither search_system nor llm is provided."""
def test_calls_get_llm(
self, mock_get_llm, mock_advanced_search_system, mock_get_setting
):
IntegratedReportGenerator()
mock_get_llm.assert_called_once()
def test_creates_advanced_search_system_with_default_model(
self, mock_get_llm, mock_advanced_search_system, mock_get_setting
):
IntegratedReportGenerator()
mock_advanced_search_system.assert_called_once_with(
llm=mock_get_llm.return_value
)
# ---------------------------------------------------------------------------
# Settings / attributes
# ---------------------------------------------------------------------------
class TestSettings:
"""Tests for configurable attributes set during __init__."""
def test_searches_per_section_defaults_to_2(
self, mock_get_llm, mock_advanced_search_system, mock_get_setting
):
gen = IntegratedReportGenerator()
assert gen.searches_per_section == 2
def test_custom_searches_per_section_is_stored(
self, mock_get_llm, mock_advanced_search_system, mock_get_setting
):
gen = IntegratedReportGenerator(searches_per_section=5)
assert gen.searches_per_section == 5
def test_max_context_sections_reads_from_snapshot(
self, mock_get_llm, mock_advanced_search_system, mock_get_setting
):
snapshot = {"report.max_context_sections": 10}
mock_get_setting.side_effect = None
mock_get_setting.return_value = 10
gen = IntegratedReportGenerator(settings_snapshot=snapshot)
# Verify get_setting_from_snapshot was called with correct key
calls = mock_get_setting.call_args_list
section_call = [
c for c in calls if c[0][0] == "report.max_context_sections"
]
assert len(section_call) == 1
assert section_call[0].kwargs["settings_snapshot"] is snapshot
assert gen.max_context_sections == 10
def test_max_context_chars_reads_from_snapshot(
self, mock_get_llm, mock_advanced_search_system, mock_get_setting
):
snapshot = {"report.max_context_chars": 8000}
mock_get_setting.side_effect = None
mock_get_setting.return_value = 8000
gen = IntegratedReportGenerator(settings_snapshot=snapshot)
calls = mock_get_setting.call_args_list
chars_call = [c for c in calls if c[0][0] == "report.max_context_chars"]
assert len(chars_call) == 1
assert chars_call[0].kwargs["settings_snapshot"] is snapshot
assert gen.max_context_chars == 8000
def test_defaults_for_max_context_sections_and_chars(
self, mock_get_llm, mock_advanced_search_system, mock_get_setting
):
gen = IntegratedReportGenerator()
# mock_get_setting side_effect returns the default kwarg,
# so we expect the source-code defaults: 3 and 4000
assert gen.max_context_sections == 3
assert gen.max_context_chars == 4000
+420
View File
@@ -0,0 +1,420 @@
"""
Tests for search_system.py - AdvancedSearchSystem class.
Tests cover:
- __init__ settings extraction, defaults, factory wiring, follow-up delegation, logging
- set_progress_callback forwarding to strategy
- analyze_topic search_id generation and delegation
- _perform_search settings extraction, progress callbacks, result assembly
- Duplicate link avoidance (id check)
- NewsSearchCallback error resilience
"""
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
def _make_strategy_mock():
"""Return a MagicMock that behaves like a strategy returned by create_strategy."""
strategy = MagicMock()
strategy.analyze_topic.return_value = {"current_knowledge": "test"}
strategy.questions_by_iteration = []
strategy.all_links_of_system = []
strategy.set_progress_callback = MagicMock()
return strategy
# Patch paths: top-level imports can be patched on search_system directly,
# but create_strategy and NewsSearchCallback are lazily imported inside method
# bodies, so they must be patched at their source modules.
PATCH_CITATION = "local_deep_research.search_system.CitationHandler"
PATCH_QUESTION_GEN = (
"local_deep_research.search_system.StandardQuestionGenerator"
)
PATCH_FINDINGS = "local_deep_research.search_system.FindingsRepository"
PATCH_CREATE_STRATEGY = (
"local_deep_research.search_system_factory.create_strategy"
)
PATCH_ENHANCED_FOLLOWUP = (
"local_deep_research.search_system.EnhancedContextualFollowUpStrategy"
)
PATCH_NEWS_CALLBACK = (
"local_deep_research.news.core.search_integration.NewsSearchCallback"
)
@pytest.fixture()
def mock_deps():
"""Patch CitationHandler, StandardQuestionGenerator, FindingsRepository,
and create_strategy so that AdvancedSearchSystem can be instantiated
without real dependencies. Yields a dict of mock objects."""
with (
patch(PATCH_CITATION) as m_citation,
patch(PATCH_QUESTION_GEN) as m_qgen,
patch(PATCH_FINDINGS) as m_findings,
patch(PATCH_CREATE_STRATEGY) as m_create,
):
strategy = _make_strategy_mock()
m_create.return_value = strategy
yield {
"citation": m_citation,
"question_gen": m_qgen,
"findings": m_findings,
"create_strategy": m_create,
"strategy": strategy,
}
@pytest.fixture()
def mock_model():
return MagicMock(name="BaseChatModel")
@pytest.fixture()
def mock_search():
return MagicMock(name="BaseSearchEngine")
def _build_system(mock_model, mock_search, mock_deps, **kwargs):
"""Convenience: build an AdvancedSearchSystem with the standard mocks."""
from local_deep_research.search_system import AdvancedSearchSystem
return AdvancedSearchSystem(llm=mock_model, search=mock_search, **kwargs)
# ===================================================================
# __init__ tests
# ===================================================================
class TestInit:
"""Tests for AdvancedSearchSystem.__init__."""
def test_settings_snapshot_defaults_to_empty_dict(
self, mock_model, mock_search, mock_deps
):
system = _build_system(
mock_model, mock_search, mock_deps, settings_snapshot=None
)
assert system.settings_snapshot == {}
# -- max_iterations -------------------------------------------------
def test_max_iterations_default_is_one(
self, mock_model, mock_search, mock_deps
):
system = _build_system(mock_model, mock_search, mock_deps)
assert system.max_iterations == 1
def test_max_iterations_from_settings_plain_value(
self, mock_model, mock_search, mock_deps
):
system = _build_system(
mock_model,
mock_search,
mock_deps,
settings_snapshot={"search.iterations": 5},
)
assert system.max_iterations == 5
def test_max_iterations_extracts_value_from_dict(
self, mock_model, mock_search, mock_deps
):
system = _build_system(
mock_model,
mock_search,
mock_deps,
settings_snapshot={"search.iterations": {"value": 8}},
)
assert system.max_iterations == 8
def test_max_iterations_explicit_overrides_snapshot(
self, mock_model, mock_search, mock_deps
):
system = _build_system(
mock_model,
mock_search,
mock_deps,
max_iterations=12,
settings_snapshot={"search.iterations": {"value": 8}},
)
assert system.max_iterations == 12
# -- questions_per_iteration ----------------------------------------
def test_questions_per_iteration_default_is_three(
self, mock_model, mock_search, mock_deps
):
system = _build_system(mock_model, mock_search, mock_deps)
assert system.questions_per_iteration == 3
def test_questions_per_iteration_from_settings_dict(
self, mock_model, mock_search, mock_deps
):
system = _build_system(
mock_model,
mock_search,
mock_deps,
settings_snapshot={"search.questions_per_iteration": {"value": 7}},
)
assert system.questions_per_iteration == 7
# -- strategy creation via factory ----------------------------------
def test_calls_create_strategy_factory(
self, mock_model, mock_search, mock_deps
):
_build_system(mock_model, mock_search, mock_deps)
mock_deps["create_strategy"].assert_called_once()
# -- enhanced-contextual-followup strategy --------------------------
def test_enhanced_contextual_followup_creates_delegate(
self, mock_model, mock_search, mock_deps
):
with patch(PATCH_ENHANCED_FOLLOWUP) as m_followup:
m_followup.return_value = _make_strategy_mock()
system = _build_system(
mock_model,
mock_search,
mock_deps,
strategy_name="enhanced-contextual-followup",
)
# create_strategy should have been called for the delegate
mock_deps["create_strategy"].assert_called_once()
delegate_kwargs = mock_deps["create_strategy"].call_args[1]
assert delegate_kwargs["strategy_name"] == "source-based"
# EnhancedContextualFollowUpStrategy should have been instantiated
m_followup.assert_called_once()
assert system.strategy is m_followup.return_value
# -- programmatic_mode warning --------------------------------------
def test_programmatic_mode_logs_warning(
self, mock_model, mock_search, mock_deps
):
with patch("local_deep_research.search_system.logger") as m_logger:
_build_system(
mock_model, mock_search, mock_deps, programmatic_mode=True
)
m_logger.warning.assert_called_once()
assert (
"programmatic mode" in m_logger.warning.call_args[0][0].lower()
)
# ===================================================================
# set_progress_callback tests
# ===================================================================
class TestSetProgressCallback:
"""Tests for set_progress_callback."""
def test_forwards_callback_to_strategy(
self, mock_model, mock_search, mock_deps
):
system = _build_system(mock_model, mock_search, mock_deps)
cb = MagicMock(name="my_callback")
system.set_progress_callback(cb)
mock_deps["strategy"].set_progress_callback.assert_called_with(cb)
# ===================================================================
# analyze_topic tests
# ===================================================================
class TestAnalyzeTopic:
"""Tests for analyze_topic."""
def test_generates_search_id_when_not_provided(
self, mock_model, mock_search, mock_deps
):
system = _build_system(mock_model, mock_search, mock_deps)
with patch.object(system, "_perform_search", return_value={}) as m_ps:
system.analyze_topic("quantum computing")
# search_id (2nd positional arg) should be a non-empty string
search_id = m_ps.call_args[0][1]
assert isinstance(search_id, str) and len(search_id) > 0
def test_passes_explicit_search_id(
self, mock_model, mock_search, mock_deps
):
system = _build_system(mock_model, mock_search, mock_deps)
with patch.object(system, "_perform_search", return_value={}) as m_ps:
system.analyze_topic("q", search_id="my-id-99")
assert m_ps.call_args[0][1] == "my-id-99"
def test_returns_perform_search_result(
self, mock_model, mock_search, mock_deps
):
system = _build_system(mock_model, mock_search, mock_deps)
expected = {"current_knowledge": "deep"}
with patch.object(system, "_perform_search", return_value=expected):
result = system.analyze_topic("topic")
assert result is expected
# ===================================================================
# _perform_search tests
# ===================================================================
class TestPerformSearch:
"""Tests for _perform_search."""
def _run_perform_search(
self,
mock_model,
mock_search,
mock_deps,
settings=None,
query="test q",
):
system = _build_system(
mock_model,
mock_search,
mock_deps,
settings_snapshot=settings or {},
)
progress_calls = []
system.progress_callback = lambda m, p, md: progress_calls.append(
(m, p, md)
)
with patch(PATCH_NEWS_CALLBACK):
result = system._perform_search(
query, "sid-1", True, False, "user1"
)
return system, result, progress_calls
def test_extracts_llm_provider_from_settings(
self, mock_model, mock_search, mock_deps
):
settings = {"llm.provider": {"value": "anthropic"}}
_, _, calls = self._run_perform_search(
mock_model, mock_search, mock_deps, settings=settings
)
assert any("anthropic" in str(c) for c in calls)
def test_extracts_llm_model_from_settings(
self, mock_model, mock_search, mock_deps
):
settings = {"llm.model": "claude-3-opus"}
_, _, calls = self._run_perform_search(
mock_model, mock_search, mock_deps, settings=settings
)
assert any("claude-3-opus" in str(c) for c in calls)
def test_extracts_search_tool_from_settings(
self, mock_model, mock_search, mock_deps
):
settings = {"search.tool": {"value": "google"}}
_, _, calls = self._run_perform_search(
mock_model, mock_search, mock_deps, settings=settings
)
assert any("google" in str(c) for c in calls)
def test_calls_progress_callback_with_setup_info(
self, mock_model, mock_search, mock_deps
):
_, _, calls = self._run_perform_search(
mock_model, mock_search, mock_deps
)
# At least two calls: one for LLM info, one for search tool info
assert len(calls) >= 2
phases = [c[2].get("phase") for c in calls]
assert "setup" in phases
def test_calls_strategy_analyze_topic(
self, mock_model, mock_search, mock_deps
):
self._run_perform_search(
mock_model, mock_search, mock_deps, query="my query"
)
mock_deps["strategy"].analyze_topic.assert_called_once_with("my query")
def test_updates_questions_by_iteration(
self, mock_model, mock_search, mock_deps
):
mock_deps["strategy"].questions_by_iteration = ["q1", "q2"]
system, result, _ = self._run_perform_search(
mock_model, mock_search, mock_deps
)
assert system.questions_by_iteration == ["q1", "q2"]
assert result["questions_by_iteration"] == ["q1", "q2"]
def test_result_contains_search_system(
self, mock_model, mock_search, mock_deps
):
system, result, _ = self._run_perform_search(
mock_model, mock_search, mock_deps
)
assert result["search_system"] is system
def test_result_contains_all_links_of_system(
self, mock_model, mock_search, mock_deps
):
mock_deps["strategy"].all_links_of_system = [{"url": "http://a.com"}]
_, result, _ = self._run_perform_search(
mock_model, mock_search, mock_deps
)
assert "all_links_of_system" in result
def test_result_contains_query(self, mock_model, mock_search, mock_deps):
_, result, _ = self._run_perform_search(
mock_model, mock_search, mock_deps, query="deep research"
)
assert result["query"] == "deep research"
def test_avoids_duplicate_links_when_same_object(
self, mock_model, mock_search, mock_deps
):
"""When strategy.all_links_of_system is the SAME object as
system.all_links_of_system, no extend should happen (id check)."""
system = _build_system(mock_model, mock_search, mock_deps)
# Make the strategy share the exact same list object
mock_deps["strategy"].all_links_of_system = system.all_links_of_system
system.all_links_of_system.extend(
[{"url": "http://a.com"}, {"url": "http://b.com"}]
)
system.progress_callback = lambda m, p, md: None
with patch(PATCH_NEWS_CALLBACK):
system._perform_search("q", "sid", True, False, "u")
# If id-check were removed, self-extend would double to 4
assert len(system.all_links_of_system) == 2
def test_extends_links_when_different_objects(
self, mock_model, mock_search, mock_deps
):
"""When strategy.all_links_of_system is a different object,
the links should be extended."""
system = _build_system(mock_model, mock_search, mock_deps)
strategy_links = [{"url": "http://new.com"}]
mock_deps["strategy"].all_links_of_system = strategy_links
system.progress_callback = lambda m, p, md: None
with patch(PATCH_NEWS_CALLBACK):
system._perform_search("q", "sid", True, False, "u")
assert {"url": "http://new.com"} in system.all_links_of_system
def test_news_callback_error_does_not_break_search(
self, mock_model, mock_search, mock_deps
):
system = _build_system(mock_model, mock_search, mock_deps)
system.progress_callback = lambda m, p, md: None
with patch(PATCH_NEWS_CALLBACK, side_effect=Exception("boom")):
# Should not raise
result = system._perform_search("q", "sid", True, False, "u")
assert isinstance(result, dict)
+415
View File
@@ -0,0 +1,415 @@
"""Extended tests for search_system.py - covering settings processing, programmatic mode,
strategy selection, and result processing."""
from unittest.mock import Mock, patch
def _make_strategy_mock():
"""Create a mock strategy with required attributes."""
mock_strategy = Mock()
mock_strategy.questions_by_iteration = []
mock_strategy.all_links_of_system = []
return mock_strategy
def _patch_system():
"""Return nested context managers for search system patching.
Usage:
with _create_patched():
...
Returns a helper that patches both create_strategy and StandardCitationHandler.
"""
class PatchContext:
def __init__(self):
self.strategy = _make_strategy_mock()
self._p1 = patch(
"local_deep_research.search_system_factory.create_strategy",
return_value=self.strategy,
)
self._p2 = patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler",
return_value=Mock(
_create_documents=Mock(), _format_sources=Mock()
),
)
def __enter__(self):
self.mock_create = self._p1.__enter__()
self.mock_citation = self._p2.__enter__()
return self
def __exit__(self, *args):
self._p2.__exit__(*args)
self._p1.__exit__(*args)
return PatchContext()
class TestAdvancedSearchSystemSettingsProcessing:
"""Tests for settings snapshot processing in AdvancedSearchSystem.__init__."""
def test_max_iterations_from_dict_settings(self):
"""max_iterations should be extracted from dict-wrapped settings."""
settings = {
"search.iterations": {"value": 5, "ui_element": "number"},
"search.questions_per_iteration": {
"value": 4,
"ui_element": "number",
},
}
with _patch_system():
from local_deep_research.search_system import AdvancedSearchSystem
system = AdvancedSearchSystem(
llm=Mock(), search=Mock(), settings_snapshot=settings
)
assert system.max_iterations == 5
assert system.questions_per_iteration == 4
def test_max_iterations_from_direct_value_settings(self):
"""max_iterations should work with direct (non-dict) settings values."""
settings = {
"search.iterations": 3,
"search.questions_per_iteration": 2,
}
with _patch_system():
from local_deep_research.search_system import AdvancedSearchSystem
system = AdvancedSearchSystem(
llm=Mock(), search=Mock(), settings_snapshot=settings
)
assert system.max_iterations == 3
assert system.questions_per_iteration == 2
def test_max_iterations_defaults_when_missing(self):
"""max_iterations should default to 1 when not in settings."""
with _patch_system():
from local_deep_research.search_system import AdvancedSearchSystem
system = AdvancedSearchSystem(
llm=Mock(), search=Mock(), settings_snapshot={}
)
assert system.max_iterations == 1
assert system.questions_per_iteration == 3
def test_explicit_max_iterations_overrides_settings(self):
"""Explicit max_iterations parameter should override settings snapshot."""
settings = {"search.iterations": {"value": 10}}
with _patch_system():
from local_deep_research.search_system import AdvancedSearchSystem
system = AdvancedSearchSystem(
llm=Mock(),
search=Mock(),
max_iterations=7,
settings_snapshot=settings,
)
assert system.max_iterations == 7
class TestAdvancedSearchSystemProgrammaticMode:
"""Tests for programmatic mode behavior."""
def test_programmatic_mode_sets_flag(self):
"""programmatic_mode flag should be stored."""
with _patch_system():
from local_deep_research.search_system import AdvancedSearchSystem
system = AdvancedSearchSystem(
llm=Mock(), search=Mock(), programmatic_mode=True
)
assert system.programmatic_mode is True
def test_non_programmatic_mode_default(self):
"""programmatic_mode should default to False."""
with _patch_system():
from local_deep_research.search_system import AdvancedSearchSystem
system = AdvancedSearchSystem(llm=Mock(), search=Mock())
assert system.programmatic_mode is False
class TestAdvancedSearchSystemResearchContext:
"""Tests for research context handling."""
def test_stores_research_context(self):
"""research_context should be stored on the system."""
context = {"delegate_strategy": "rapid", "some_key": "some_value"}
with _patch_system():
from local_deep_research.search_system import AdvancedSearchSystem
system = AdvancedSearchSystem(
llm=Mock(), search=Mock(), research_context=context
)
assert system.research_context == context
def test_none_research_context(self):
"""None research_context should not cause errors."""
with _patch_system():
from local_deep_research.search_system import AdvancedSearchSystem
system = AdvancedSearchSystem(
llm=Mock(), search=Mock(), research_context=None
)
assert system.research_context is None
def test_research_id_stored(self):
"""research_id should be stored on the system."""
with _patch_system():
from local_deep_research.search_system import AdvancedSearchSystem
system = AdvancedSearchSystem(
llm=Mock(), search=Mock(), research_id="test-123"
)
assert system.research_id == "test-123"
class TestAdvancedSearchSystemContextualFollowUp:
"""Tests for contextual follow-up strategy initialization."""
def test_contextual_followup_uses_delegate(self):
"""Contextual follow-up strategy should create a delegate strategy."""
context = {"delegate_strategy": "rapid"}
with _patch_system() as ctx:
with patch(
"local_deep_research.search_system.EnhancedContextualFollowUpStrategy"
) as mock_followup_cls:
mock_followup_cls.return_value = Mock()
from local_deep_research.search_system import (
AdvancedSearchSystem,
)
AdvancedSearchSystem(
llm=Mock(),
search=Mock(),
strategy_name="enhanced-contextual-followup",
research_context=context,
)
# Verify delegate strategy was created with rapid
ctx.mock_create.assert_called()
call_kwargs = ctx.mock_create.call_args
assert call_kwargs.kwargs.get("strategy_name") == "rapid" or (
call_kwargs[1].get("strategy_name") == "rapid"
)
def test_contextual_followup_defaults_delegate_to_source_based(self):
"""Contextual follow-up without delegate_strategy should default to source-based."""
with _patch_system() as ctx:
with patch(
"local_deep_research.search_system.EnhancedContextualFollowUpStrategy"
) as mock_followup_cls:
mock_followup_cls.return_value = Mock()
from local_deep_research.search_system import (
AdvancedSearchSystem,
)
AdvancedSearchSystem(
llm=Mock(),
search=Mock(),
strategy_name="contextual-followup",
research_context={},
)
call_kwargs = ctx.mock_create.call_args
strategy_name = call_kwargs.kwargs.get(
"strategy_name"
) or call_kwargs[1].get("strategy_name")
assert strategy_name == "source-based"
def test_contextual_followup_name_variants(self):
"""All contextual follow-up name variants should be recognized."""
variants = [
"enhanced-contextual-followup",
"enhanced_contextual_followup",
"contextual-followup",
"contextual_followup",
]
for variant in variants:
with _patch_system():
with patch(
"local_deep_research.search_system.EnhancedContextualFollowUpStrategy"
) as mock_followup_cls:
mock_followup_cls.return_value = Mock()
from local_deep_research.search_system import (
AdvancedSearchSystem,
)
AdvancedSearchSystem(
llm=Mock(),
search=Mock(),
strategy_name=variant,
)
# Should use EnhancedContextualFollowUpStrategy
mock_followup_cls.assert_called_once()
class TestAdvancedSearchSystemSearchOriginalQuery:
"""Tests for search_original_query parameter."""
def test_search_original_query_default_true(self):
"""search_original_query should default to True."""
with _patch_system():
from local_deep_research.search_system import AdvancedSearchSystem
system = AdvancedSearchSystem(llm=Mock(), search=Mock())
assert system.search_original_query is True
def test_search_original_query_can_be_disabled(self):
"""search_original_query can be set to False for news searches."""
with _patch_system():
from local_deep_research.search_system import AdvancedSearchSystem
system = AdvancedSearchSystem(
llm=Mock(), search=Mock(), search_original_query=False
)
assert system.search_original_query is False
class TestAdvancedSearchSystemPerformSearch:
"""Tests for _perform_search result processing."""
def test_result_includes_query(self):
"""Result should include the query if not returned by strategy."""
with _patch_system() as ctx:
ctx.strategy.analyze_topic.return_value = {
"current_knowledge": "some results"
}
ctx.strategy.questions_by_iteration = {1: ["q1"]}
from local_deep_research.search_system import AdvancedSearchSystem
system = AdvancedSearchSystem(llm=Mock(), search=Mock())
with patch(
"local_deep_research.news.core.search_integration.NewsSearchCallback"
):
result = system.analyze_topic("test query")
assert result["query"] == "test query"
def test_result_includes_search_system_reference(self):
"""Result should include reference to the search system."""
with _patch_system() as ctx:
ctx.strategy.analyze_topic.return_value = {
"current_knowledge": "results"
}
ctx.strategy.questions_by_iteration = {}
from local_deep_research.search_system import AdvancedSearchSystem
system = AdvancedSearchSystem(llm=Mock(), search=Mock())
with patch(
"local_deep_research.news.core.search_integration.NewsSearchCallback"
):
result = system.analyze_topic("test query")
assert result["search_system"] is system
def test_settings_snapshot_extraction_in_perform_search(self):
"""_perform_search should extract LLM/search info from settings snapshot."""
settings = {
"llm.provider": {"value": "openai"},
"llm.model": {"value": "gpt-4"},
"search.tool": {"value": "searxng"},
}
progress_calls = []
def capture_progress(msg, progress, metadata):
progress_calls.append((msg, progress, metadata))
with _patch_system() as ctx:
ctx.strategy.analyze_topic.return_value = {"current_knowledge": "r"}
ctx.strategy.questions_by_iteration = {}
from local_deep_research.search_system import AdvancedSearchSystem
system = AdvancedSearchSystem(
llm=Mock(), search=Mock(), settings_snapshot=settings
)
system.set_progress_callback(capture_progress)
with patch(
"local_deep_research.news.core.search_integration.NewsSearchCallback"
):
system.analyze_topic("test")
# Should have progress callbacks with LLM and search info
assert len(progress_calls) >= 2
assert "openai" in progress_calls[0][0]
assert "gpt-4" in progress_calls[0][0]
def test_links_deduplication(self):
"""all_links_of_system should not be duplicated when same object."""
shared_links = [{"title": "link1", "url": "http://example.com"}]
with _patch_system() as ctx:
ctx.strategy.analyze_topic.return_value = {"current_knowledge": "r"}
ctx.strategy.questions_by_iteration = {}
ctx.strategy.all_links_of_system = shared_links
from local_deep_research.search_system import AdvancedSearchSystem
system = AdvancedSearchSystem(llm=Mock(), search=Mock())
system.all_links_of_system = shared_links
with patch(
"local_deep_research.news.core.search_integration.NewsSearchCallback"
):
system.analyze_topic("test")
# Should NOT have duplicated the links
assert len(system.all_links_of_system) == 1
class TestAdvancedSearchSystemProgressCallback:
"""Tests for progress callback mechanism."""
def test_set_progress_callback_propagates_to_strategy(self):
"""set_progress_callback should propagate to the strategy."""
with _patch_system() as ctx:
from local_deep_research.search_system import AdvancedSearchSystem
system = AdvancedSearchSystem(llm=Mock(), search=Mock())
def noop_callback(msg, pct, meta):
return None
system.set_progress_callback(noop_callback)
ctx.strategy.set_progress_callback.assert_called_with(noop_callback)
def test_progress_callback_before_strategy_exists(self):
"""Setting callback before strategy is created should not crash."""
with _patch_system():
from local_deep_research.search_system import AdvancedSearchSystem
system = AdvancedSearchSystem(llm=Mock(), search=Mock())
# Default callback should be a no-op lambda
system.progress_callback("test", 50, {})
+556
View File
@@ -0,0 +1,556 @@
"""
Tests for search_system_factory.py
Tests cover:
- _get_setting helper function (all edge cases)
- create_strategy factory function (all strategy types)
- Strategy name normalization (case-insensitive, alternative forms)
- kwargs pass-through and settings_snapshot forwarding
- Focused-iteration special behaviors (zero-to-None, flexible generator)
- Iterative-refinement recursive create_strategy call
- Unknown strategy fallback with warning
"""
from unittest.mock import MagicMock, Mock, patch
import pytest
from langchain_core.language_models import BaseChatModel
from local_deep_research.search_system_factory import (
_get_setting,
create_strategy,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_model():
"""Return a MagicMock with BaseChatModel spec."""
return MagicMock(spec=BaseChatModel)
@pytest.fixture
def mock_search():
"""Return a plain MagicMock for the search engine."""
return MagicMock()
# ===========================================================================
# _get_setting tests
# ===========================================================================
class TestGetSetting:
"""Tests for _get_setting helper function."""
def test_returns_default_when_snapshot_is_none(self):
assert _get_setting(None, "any.key", "default_val") == "default_val"
def test_returns_default_when_snapshot_is_empty_dict(self):
# Empty dict is falsy; the guard `if not settings_snapshot` catches it.
assert _get_setting({}, "missing.key", 42) == 42
def test_returns_default_when_key_not_in_snapshot(self):
snapshot = {"other.key": "other_val"}
assert _get_setting(snapshot, "missing.key", "fallback") == "fallback"
def test_returns_raw_value_when_value_is_not_dict(self):
snapshot = {"my.key": "plain_string"}
assert _get_setting(snapshot, "my.key", "default") == "plain_string"
def test_returns_integer_value_directly(self):
snapshot = {"iterations": 7}
assert _get_setting(snapshot, "iterations", 10) == 7
def test_returns_boolean_value_directly(self):
snapshot = {"enabled": True}
assert _get_setting(snapshot, "enabled", False) is True
def test_extracts_value_from_dict_with_value_key(self):
snapshot = {"my.key": {"value": "nested_result", "type": "string"}}
assert _get_setting(snapshot, "my.key", "default") == "nested_result"
def test_returns_dict_itself_when_no_value_key(self):
inner = {"type": "string", "description": "no value key here"}
snapshot = {"my.key": inner}
assert _get_setting(snapshot, "my.key", "default") == inner
def test_extracts_none_from_value_key(self):
"""Even if value['value'] is None, it should be returned (not default)."""
snapshot = {"my.key": {"value": None}}
assert _get_setting(snapshot, "my.key", "default") is None
def test_extracts_zero_from_value_key(self):
"""Zero is a valid value and should not be replaced by default."""
snapshot = {"limit": {"value": 0}}
assert _get_setting(snapshot, "limit", 10) == 0
def test_returns_list_value_directly(self):
snapshot = {"tags": ["a", "b", "c"]}
assert _get_setting(snapshot, "tags", []) == ["a", "b", "c"]
# ===========================================================================
# create_strategy tests individual strategies
# ===========================================================================
# Helpers: common patch paths
_STRAT_BASE = "local_deep_research.advanced_search_system.strategies"
class TestCreateStrategySourceBased:
"""Tests for source-based strategy and its name variants."""
PATCH_PATH = (
f"{_STRAT_BASE}.source_based_strategy.SourceBasedSearchStrategy"
)
@pytest.mark.parametrize(
"name",
["source-based", "source_based", "source_based_search"],
)
def test_all_name_variants_create_source_based(
self, name, mock_model, mock_search
):
with patch(self.PATCH_PATH) as cls:
cls.return_value = Mock()
result = create_strategy(
strategy_name=name, model=mock_model, search=mock_search
)
cls.assert_called_once()
assert result == cls.return_value
def test_kwargs_passed_through(self, mock_model, mock_search):
with patch(self.PATCH_PATH) as cls:
cls.return_value = Mock()
create_strategy(
strategy_name="source-based",
model=mock_model,
search=mock_search,
include_text_content=False,
use_cross_engine_filter=False,
use_atomic_facts=True,
search_original_query=False,
)
kw = cls.call_args[1]
assert kw["include_text_content"] is False
assert kw["use_cross_engine_filter"] is False
assert kw["use_atomic_facts"] is True
assert kw["search_original_query"] is False
def test_settings_snapshot_forwarded(self, mock_model, mock_search):
snapshot = {"some.setting": 123}
with patch(self.PATCH_PATH) as cls:
cls.return_value = Mock()
create_strategy(
strategy_name="source-based",
model=mock_model,
search=mock_search,
settings_snapshot=snapshot,
)
kw = cls.call_args[1]
assert kw["settings_snapshot"] is snapshot
class TestCreateStrategyFocusedIteration:
"""Tests for focused-iteration strategy and its special behaviors."""
PATCH_PATH = (
f"{_STRAT_BASE}.focused_iteration_strategy.FocusedIterationStrategy"
)
@pytest.mark.parametrize("name", ["focused-iteration", "focused_iteration"])
def test_name_variants(self, name, mock_model, mock_search):
with patch(self.PATCH_PATH) as cls:
cls.return_value = Mock()
result = create_strategy(
strategy_name=name, model=mock_model, search=mock_search
)
cls.assert_called_once()
assert result == cls.return_value
def test_knowledge_limit_zero_converts_to_none(
self, mock_model, mock_search
):
with patch(self.PATCH_PATH) as cls:
cls.return_value = Mock()
create_strategy(
strategy_name="focused-iteration",
model=mock_model,
search=mock_search,
knowledge_summary_limit=0,
)
kw = cls.call_args[1]
assert kw["knowledge_summary_limit"] is None
def test_snippet_truncate_zero_converts_to_none(
self, mock_model, mock_search
):
with patch(self.PATCH_PATH) as cls:
cls.return_value = Mock()
create_strategy(
strategy_name="focused-iteration",
model=mock_model,
search=mock_search,
knowledge_snippet_truncate=0,
)
kw = cls.call_args[1]
assert kw["knowledge_snippet_truncate"] is None
def test_prompt_knowledge_truncate_zero_converts_to_none(
self, mock_model, mock_search
):
with patch(self.PATCH_PATH) as cls:
cls.return_value = Mock()
create_strategy(
strategy_name="focused-iteration",
model=mock_model,
search=mock_search,
prompt_knowledge_truncate=0,
)
kw = cls.call_args[1]
assert kw["prompt_knowledge_truncate"] is None
def test_previous_searches_limit_zero_converts_to_none(
self, mock_model, mock_search
):
with patch(self.PATCH_PATH) as cls:
cls.return_value = Mock()
create_strategy(
strategy_name="focused-iteration",
model=mock_model,
search=mock_search,
previous_searches_limit=0,
)
kw = cls.call_args[1]
assert kw["previous_searches_limit"] is None
def test_nonzero_limits_stay_unchanged(self, mock_model, mock_search):
with patch(self.PATCH_PATH) as cls:
cls.return_value = Mock()
create_strategy(
strategy_name="focused-iteration",
model=mock_model,
search=mock_search,
knowledge_summary_limit=5,
knowledge_snippet_truncate=200,
)
kw = cls.call_args[1]
assert kw["knowledge_summary_limit"] == 5
assert kw["knowledge_snippet_truncate"] == 200
def test_flexible_question_generator_overrides(
self, mock_model, mock_search
):
flex_gen_path = "local_deep_research.advanced_search_system.questions.flexible_browsecomp_question.FlexibleBrowseCompQuestionGenerator"
with patch(self.PATCH_PATH) as cls, patch(flex_gen_path) as flex_cls:
strategy_instance = Mock()
cls.return_value = strategy_instance
flex_cls.return_value = Mock()
create_strategy(
strategy_name="focused-iteration",
model=mock_model,
search=mock_search,
question_generator="flexible",
)
flex_cls.assert_called_once()
# The generator is assigned to the strategy's question_generator
assert strategy_instance.question_generator == flex_cls.return_value
def test_non_flexible_generator_does_not_override(
self, mock_model, mock_search
):
flex_gen_path = "local_deep_research.advanced_search_system.questions.flexible_browsecomp_question.FlexibleBrowseCompQuestionGenerator"
with patch(self.PATCH_PATH) as cls, patch(flex_gen_path) as flex_cls:
strategy_instance = Mock()
cls.return_value = strategy_instance
create_strategy(
strategy_name="focused-iteration",
model=mock_model,
search=mock_search,
question_generator="browsecomp",
)
flex_cls.assert_not_called()
def test_settings_snapshot_read_for_focused_iteration(
self, mock_model, mock_search
):
settings = {
"focused_iteration.adaptive_questions": 1,
"focused_iteration.knowledge_summary_limit": 20,
"focused_iteration.snippet_truncate": 300,
}
with patch(self.PATCH_PATH) as cls:
cls.return_value = Mock()
create_strategy(
strategy_name="focused-iteration",
model=mock_model,
search=mock_search,
settings_snapshot=settings,
)
kw = cls.call_args[1]
assert kw["enable_adaptive_questions"] is True
assert kw["knowledge_summary_limit"] == 20
assert kw["knowledge_snippet_truncate"] == 300
def test_kwargs_override_settings_snapshot(self, mock_model, mock_search):
settings = {
"focused_iteration.knowledge_summary_limit": 20,
}
with patch(self.PATCH_PATH) as cls:
cls.return_value = Mock()
create_strategy(
strategy_name="focused-iteration",
model=mock_model,
search=mock_search,
settings_snapshot=settings,
knowledge_summary_limit=99,
)
kw = cls.call_args[1]
assert kw["knowledge_summary_limit"] == 99
class TestCreateStrategyFocusedIterationStandard:
"""Tests for focused-iteration-standard (with standard citation handler)."""
PATCH_PATH = (
f"{_STRAT_BASE}.focused_iteration_strategy.FocusedIterationStrategy"
)
CITATION_PATH = "local_deep_research.citation_handler.CitationHandler"
@pytest.mark.parametrize(
"name",
["focused-iteration-standard", "focused_iteration_standard"],
)
def test_name_variants(self, name, mock_model, mock_search):
with patch(self.PATCH_PATH) as cls, patch(self.CITATION_PATH):
cls.return_value = Mock()
result = create_strategy(
strategy_name=name, model=mock_model, search=mock_search
)
cls.assert_called_once()
assert result == cls.return_value
def test_citation_handler_created_with_standard_type(
self, mock_model, mock_search
):
with (
patch(self.PATCH_PATH) as cls,
patch(self.CITATION_PATH) as cite_cls,
):
cls.return_value = Mock()
create_strategy(
strategy_name="focused-iteration-standard",
model=mock_model,
search=mock_search,
)
cite_cls.assert_called_once()
cite_kw = cite_cls.call_args
assert cite_kw[1]["handler_type"] == "standard"
class TestCreateStrategyNews:
"""Tests for news strategy."""
PATCH_PATH = f"{_STRAT_BASE}.news_strategy.NewsAggregationStrategy"
@pytest.mark.parametrize(
"name", ["news", "news_aggregation", "news-aggregation"]
)
def test_name_variants(self, name, mock_model, mock_search):
with patch(self.PATCH_PATH) as cls:
cls.return_value = Mock()
result = create_strategy(
strategy_name=name, model=mock_model, search=mock_search
)
cls.assert_called_once()
assert result == cls.return_value
# ===========================================================================
# Case-insensitivity tests
# ===========================================================================
class TestCaseInsensitivity:
"""Strategy name matching is case-insensitive via .lower()."""
def test_uppercase_topic_organization(self, mock_model, mock_search):
with patch(
f"{_STRAT_BASE}.topic_organization_strategy.TopicOrganizationStrategy"
) as cls:
cls.return_value = Mock()
create_strategy(
strategy_name="TOPIC-ORGANIZATION",
model=mock_model,
search=mock_search,
)
cls.assert_called_once()
def test_mixed_case_source_based(self, mock_model, mock_search):
with patch(
f"{_STRAT_BASE}.source_based_strategy.SourceBasedSearchStrategy"
) as cls:
cls.return_value = Mock()
create_strategy(
strategy_name="Source-Based",
model=mock_model,
search=mock_search,
)
cls.assert_called_once()
def test_uppercase_news(self, mock_model, mock_search):
with patch(
f"{_STRAT_BASE}.news_strategy.NewsAggregationStrategy"
) as cls:
cls.return_value = Mock()
create_strategy(
strategy_name="NEWS",
model=mock_model,
search=mock_search,
)
cls.assert_called_once()
def test_mixed_case_focused_iteration(self, mock_model, mock_search):
with patch(
f"{_STRAT_BASE}.focused_iteration_strategy.FocusedIterationStrategy"
) as cls:
cls.return_value = Mock()
create_strategy(
strategy_name="Focused-Iteration",
model=mock_model,
search=mock_search,
)
cls.assert_called_once()
# ===========================================================================
# Unknown strategy fallback
# ===========================================================================
class TestUnknownStrategyFallback:
"""Unknown strategy names fall back to SourceBasedSearchStrategy."""
PATCH_PATH = (
f"{_STRAT_BASE}.source_based_strategy.SourceBasedSearchStrategy"
)
def test_unknown_strategy_falls_back_to_source_based(
self, mock_model, mock_search
):
with patch(self.PATCH_PATH) as cls:
cls.return_value = Mock()
result = create_strategy(
strategy_name="unknown-strategy-xyz",
model=mock_model,
search=mock_search,
)
cls.assert_called_once()
assert result == cls.return_value
def test_unknown_strategy_logs_warning(self, mock_model, mock_search):
with (
patch(self.PATCH_PATH) as cls,
patch(
"local_deep_research.search_system_factory.logger"
) as mock_logger,
):
cls.return_value = Mock()
create_strategy(
strategy_name="unknown-strategy-xyz",
model=mock_model,
search=mock_search,
)
mock_logger.warning.assert_called_once()
warning_msg = mock_logger.warning.call_args[0][0]
assert "unknown-strategy-xyz" in warning_msg.lower()
def test_another_unknown_name(self, mock_model, mock_search):
with patch(self.PATCH_PATH) as cls:
cls.return_value = Mock()
create_strategy(
strategy_name="totally-made-up",
model=mock_model,
search=mock_search,
)
cls.assert_called_once()
def test_fallback_uses_hardcoded_defaults(self, mock_model, mock_search):
with patch(self.PATCH_PATH) as cls:
cls.return_value = Mock()
create_strategy(
strategy_name="nonexistent",
model=mock_model,
search=mock_search,
)
kw = cls.call_args[1]
assert kw["include_text_content"] is True
assert kw["use_cross_engine_filter"] is True
assert kw["use_atomic_facts"] is False
# ===========================================================================
# all_links_of_system handling
# ===========================================================================
class TestAllLinksHandling:
"""Tests for all_links_of_system parameter normalization."""
def test_none_becomes_empty_list(self, mock_model, mock_search):
with patch(
f"{_STRAT_BASE}.source_based_strategy.SourceBasedSearchStrategy"
) as cls:
cls.return_value = Mock()
create_strategy(
strategy_name="source-based",
model=mock_model,
search=mock_search,
all_links_of_system=None,
)
kw = cls.call_args[1]
assert kw["all_links_of_system"] == []
def test_existing_links_passed_through(self, mock_model, mock_search):
links = [{"link": "http://a.com"}, {"link": "http://b.com"}]
with patch(
f"{_STRAT_BASE}.source_based_strategy.SourceBasedSearchStrategy"
) as cls:
cls.return_value = Mock()
create_strategy(
strategy_name="source-based",
model=mock_model,
search=mock_search,
all_links_of_system=links,
)
kw = cls.call_args[1]
assert kw["all_links_of_system"] is links
class TestCreateStrategyTopicOrganization:
"""Tests for topic-organization strategy."""
PATCH_PATH = (
f"{_STRAT_BASE}.topic_organization_strategy.TopicOrganizationStrategy"
)
@pytest.mark.parametrize(
"name", ["topic-organization", "topic_organization", "topic"]
)
def test_name_variants(self, name, mock_model, mock_search):
with patch(self.PATCH_PATH) as cls:
cls.return_value = Mock()
result = create_strategy(
strategy_name=name, model=mock_model, search=mock_search
)
cls.assert_called_once()
assert result == cls.return_value
@@ -0,0 +1,306 @@
"""Extended tests for search_system_factory.py - covering _get_setting helper,
strategy creation edge cases, and unknown strategy fallback."""
from unittest.mock import Mock
class TestGetSettingHelper:
"""Tests for _get_setting utility function."""
def test_returns_default_when_no_snapshot(self):
"""Should return default when settings_snapshot is None."""
from local_deep_research.search_system_factory import _get_setting
result = _get_setting(None, "key", "default_value")
assert result == "default_value"
def test_returns_default_when_empty_snapshot(self):
"""Should return default when settings_snapshot is empty dict."""
from local_deep_research.search_system_factory import _get_setting
result = _get_setting({}, "key", "default_value")
assert result == "default_value"
def test_returns_default_when_key_missing(self):
"""Should return default when key is not in snapshot."""
from local_deep_research.search_system_factory import _get_setting
result = _get_setting({"other_key": "value"}, "key", "default_value")
assert result == "default_value"
def test_extracts_value_from_dict(self):
"""Should extract value from dict-wrapped setting."""
from local_deep_research.search_system_factory import _get_setting
snapshot = {"key": {"value": 42, "ui_element": "number"}}
result = _get_setting(snapshot, "key", 0)
assert result == 42
def test_returns_direct_value(self):
"""Should return direct (non-dict) value."""
from local_deep_research.search_system_factory import _get_setting
snapshot = {"key": "direct_string"}
result = _get_setting(snapshot, "key", "default")
assert result == "direct_string"
def test_returns_dict_without_value_key_as_is(self):
"""Dict without 'value' key should be returned as-is."""
from local_deep_research.search_system_factory import _get_setting
snapshot = {"key": {"other": "data"}}
result = _get_setting(snapshot, "key", "default")
assert result == {"other": "data"}
def test_handles_boolean_values(self):
"""Should handle boolean values in settings."""
from local_deep_research.search_system_factory import _get_setting
snapshot = {"key": {"value": True}}
result = _get_setting(snapshot, "key", False)
assert result is True
def test_handles_none_value_in_dict(self):
"""Should handle None as value in dict-wrapped setting."""
from local_deep_research.search_system_factory import _get_setting
snapshot = {"key": {"value": None}}
result = _get_setting(snapshot, "key", "default")
assert result is None
def test_handles_zero_value(self):
"""Should handle 0 value correctly (not confuse with None)."""
from local_deep_research.search_system_factory import _get_setting
snapshot = {"key": {"value": 0}}
result = _get_setting(snapshot, "key", 5)
assert result == 0
def test_handles_empty_string_value(self):
"""Should handle empty string value."""
from local_deep_research.search_system_factory import _get_setting
snapshot = {"key": {"value": ""}}
result = _get_setting(snapshot, "key", "default")
assert result == ""
class TestCreateStrategyNames:
"""Tests for strategy name handling in create_strategy."""
def test_source_based_variants(self):
"""All source-based name variants should create SourceBasedSearchStrategy."""
from local_deep_research.search_system_factory import create_strategy
variants = ["source-based", "source_based", "source_based_search"]
for variant in variants:
strategy = create_strategy(
strategy_name=variant,
model=Mock(),
search=Mock(),
)
assert type(strategy).__name__ == "SourceBasedSearchStrategy", (
f"Variant '{variant}' did not create SourceBasedSearchStrategy"
)
def test_topic_organization_creates_correct_strategy(self):
"""topic-organization name should create TopicOrganizationStrategy."""
from local_deep_research.search_system_factory import create_strategy
strategy = create_strategy(
strategy_name="topic-organization",
model=Mock(),
search=Mock(),
)
assert type(strategy).__name__ == "TopicOrganizationStrategy"
class TestCreateStrategyUnknown:
"""Tests for unknown strategy name handling."""
def test_unknown_strategy_falls_back_to_source_based(self):
"""Unknown strategy name should fall back to SourceBasedSearchStrategy."""
from local_deep_research.search_system_factory import create_strategy
strategy = create_strategy(
strategy_name="nonexistent_strategy_xyz",
model=Mock(),
search=Mock(),
)
assert type(strategy).__name__ == "SourceBasedSearchStrategy"
def test_empty_string_strategy_falls_back(self):
"""Empty string strategy should fall back to source-based."""
from local_deep_research.search_system_factory import create_strategy
strategy = create_strategy(
strategy_name="",
model=Mock(),
search=Mock(),
)
assert type(strategy).__name__ == "SourceBasedSearchStrategy"
class TestCreateStrategyKwargsPassthrough:
"""Tests for kwargs passthrough to strategies."""
def test_source_based_receives_kwargs(self):
"""Source-based strategy should receive kwargs."""
from local_deep_research.search_system_factory import create_strategy
strategy = create_strategy(
strategy_name="source-based",
model=Mock(),
search=Mock(),
include_text_content=False,
use_cross_engine_filter=False,
use_atomic_facts=True,
)
assert strategy.include_text_content is False
assert strategy.use_cross_engine_filter is False
def test_focused_iteration_receives_iteration_settings(self):
"""Focused-iteration strategy should receive iteration settings from kwargs."""
from local_deep_research.search_system_factory import create_strategy
strategy = create_strategy(
strategy_name="focused-iteration",
model=Mock(),
search=Mock(),
max_iterations=15,
questions_per_iteration=5,
)
assert strategy.max_iterations == 15
class TestCreateStrategySettingsSnapshot:
"""Tests for settings snapshot integration in create_strategy."""
def test_all_links_initialized_when_none(self):
"""all_links_of_system should be initialized to empty list when None."""
from local_deep_research.search_system_factory import create_strategy
strategy = create_strategy(
strategy_name="topic-organization",
model=Mock(),
search=Mock(),
all_links_of_system=None,
)
assert strategy.all_links_of_system == []
def test_all_links_passed_through(self):
"""Provided all_links_of_system should be passed to strategy."""
from local_deep_research.search_system_factory import create_strategy
existing_links = [{"url": "http://example.com", "title": "Test"}]
strategy = create_strategy(
strategy_name="topic-organization",
model=Mock(),
search=Mock(),
all_links_of_system=existing_links,
)
assert strategy.all_links_of_system is existing_links
def test_settings_snapshot_passed_to_strategy(self):
"""Settings snapshot should be passed to strategy."""
from local_deep_research.search_system_factory import create_strategy
settings = {"some.setting": "value"}
strategy = create_strategy(
strategy_name="source-based",
model=Mock(),
search=Mock(),
settings_snapshot=settings,
)
assert strategy.settings_snapshot is settings
class TestCreateStrategyFocusedIteration:
"""Tests for focused-iteration strategy creation with settings."""
def test_focused_iteration_reads_settings(self):
"""Focused iteration should read settings from snapshot."""
from local_deep_research.search_system_factory import create_strategy
settings = {
"focused_iteration.adaptive_questions": {"value": 1},
"focused_iteration.knowledge_summary_limit": {"value": 5},
"focused_iteration.snippet_truncate": {"value": 100},
}
strategy = create_strategy(
strategy_name="focused-iteration",
model=Mock(),
search=Mock(),
settings_snapshot=settings,
)
assert strategy.enable_adaptive_questions is True
assert strategy.knowledge_summary_limit == 5
assert strategy.knowledge_snippet_truncate == 100
def test_focused_iteration_zero_converts_to_none(self):
"""Knowledge limit of 0 should be converted to None (unlimited)."""
from local_deep_research.search_system_factory import create_strategy
settings = {
"focused_iteration.knowledge_summary_limit": {"value": 0},
"focused_iteration.snippet_truncate": {"value": 0},
}
strategy = create_strategy(
strategy_name="focused-iteration",
model=Mock(),
search=Mock(),
settings_snapshot=settings,
)
assert strategy.knowledge_summary_limit is None
assert strategy.knowledge_snippet_truncate is None
def test_focused_iteration_name_variants(self):
"""Both focused-iteration name variants should work."""
from local_deep_research.search_system_factory import create_strategy
for variant in ["focused-iteration", "focused_iteration"]:
strategy = create_strategy(
strategy_name=variant,
model=Mock(),
search=Mock(),
)
assert type(strategy).__name__ == "FocusedIterationStrategy"
class TestCreateStrategyCaseInsensitivity:
"""Tests for case handling in strategy names."""
def test_lowercase_matching(self):
"""Strategy names should be matched case-insensitively."""
from local_deep_research.search_system_factory import create_strategy
# The function calls .lower() on strategy_name
strategy = create_strategy(
strategy_name="TOPIC-ORGANIZATION",
model=Mock(),
search=Mock(),
)
assert type(strategy).__name__ == "TopicOrganizationStrategy"
def test_mixed_case_matching(self):
"""Mixed case strategy names should work."""
from local_deep_research.search_system_factory import create_strategy
strategy = create_strategy(
strategy_name="Source-Based",
model=Mock(),
search=Mock(),
)
assert type(strategy).__name__ == "SourceBasedSearchStrategy"
+629
View File
@@ -0,0 +1,629 @@
"""
Tests for search_system.py - Link Deduplication and Settings Extraction
Tests cover:
- Link deduplication using object identity (id())
- Settings extraction from snapshot dictionaries
These tests address issue #301: "too many links in detailed report mode"
"""
from unittest.mock import MagicMock, Mock, patch
import pytest
class TestLinkDeduplication:
"""Tests for link deduplication behavior."""
@pytest.fixture
def mock_llm(self):
"""Create a mock LLM."""
mock = MagicMock()
mock.invoke.return_value = MagicMock(content="test response")
return mock
@pytest.fixture
def mock_search_engine(self):
"""Create a mock search engine."""
mock = MagicMock()
mock.run.return_value = []
return mock
def _create_system(
self, mock_llm, mock_search_engine, mock_strategy, **kwargs
):
"""Helper to create an AdvancedSearchSystem with mocked dependencies."""
with patch(
"local_deep_research.search_system_factory.create_strategy"
) as mock_create:
mock_create.return_value = mock_strategy
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_citation:
mock_citation.return_value = Mock(
_create_documents=Mock(), _format_sources=Mock()
)
from local_deep_research.search_system import (
AdvancedSearchSystem,
)
system = AdvancedSearchSystem(
llm=mock_llm,
search=mock_search_engine,
strategy_name="standard",
**kwargs,
)
return system
def test_same_list_object_not_duplicated(
self, mock_llm, mock_search_engine
):
"""When lists are same object, don't extend."""
mock_strategy = MagicMock()
shared_links = [{"title": "Link1", "url": "http://example.com"}]
mock_strategy.all_links_of_system = shared_links
mock_strategy.questions_by_iteration = []
mock_strategy.analyze_topic.return_value = {
"current_knowledge": "test",
"query": "test query",
}
with patch(
"local_deep_research.search_system_factory.create_strategy"
) as mock_create:
mock_create.return_value = mock_strategy
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_citation:
mock_citation.return_value = Mock(
_create_documents=Mock(), _format_sources=Mock()
)
from local_deep_research.search_system import (
AdvancedSearchSystem,
)
system = AdvancedSearchSystem(
llm=mock_llm,
search=mock_search_engine,
strategy_name="standard",
)
# Make the system's list the SAME object as strategy's list
system.all_links_of_system = shared_links
# Perform search
system.analyze_topic("test query")
# Links should NOT be duplicated
# Before the fix, this would double the list
assert len(system.all_links_of_system) == 1
def test_different_list_objects_extended(
self, mock_llm, mock_search_engine
):
"""Different objects get extended."""
mock_strategy = MagicMock()
strategy_links = [{"title": "Link1", "url": "http://example.com"}]
mock_strategy.all_links_of_system = strategy_links
mock_strategy.questions_by_iteration = []
mock_strategy.analyze_topic.return_value = {
"current_knowledge": "test",
"query": "test query",
}
with patch(
"local_deep_research.search_system_factory.create_strategy"
) as mock_create:
mock_create.return_value = mock_strategy
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_citation:
mock_citation.return_value = Mock(
_create_documents=Mock(), _format_sources=Mock()
)
from local_deep_research.search_system import (
AdvancedSearchSystem,
)
system = AdvancedSearchSystem(
llm=mock_llm,
search=mock_search_engine,
strategy_name="standard",
)
# System has a DIFFERENT list object
system.all_links_of_system = []
system.analyze_topic("test query")
# Links should be extended from strategy to system
assert len(system.all_links_of_system) == 1
def test_empty_strategy_links(self, mock_llm, mock_search_engine):
"""Empty strategy links don't cause errors."""
mock_strategy = MagicMock()
mock_strategy.all_links_of_system = []
mock_strategy.questions_by_iteration = []
mock_strategy.analyze_topic.return_value = {
"current_knowledge": "test",
"query": "test query",
}
with patch(
"local_deep_research.search_system_factory.create_strategy"
) as mock_create:
mock_create.return_value = mock_strategy
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_citation:
mock_citation.return_value = Mock(
_create_documents=Mock(), _format_sources=Mock()
)
from local_deep_research.search_system import (
AdvancedSearchSystem,
)
system = AdvancedSearchSystem(
llm=mock_llm,
search=mock_search_engine,
strategy_name="standard",
)
initial_links = [
{"title": "Existing", "url": "http://existing.com"}
]
system.all_links_of_system = initial_links
system.analyze_topic("test query")
# Existing links should remain, nothing added
assert len(system.all_links_of_system) == 1
assert system.all_links_of_system[0]["title"] == "Existing"
def test_large_link_list_performance(self, mock_llm, mock_search_engine):
"""1000+ links don't cause memory issues."""
mock_strategy = MagicMock()
large_links = [
{"title": f"Link{i}", "url": f"http://example{i}.com"}
for i in range(1000)
]
mock_strategy.all_links_of_system = large_links
mock_strategy.questions_by_iteration = []
mock_strategy.analyze_topic.return_value = {
"current_knowledge": "test",
"query": "test query",
}
with patch(
"local_deep_research.search_system_factory.create_strategy"
) as mock_create:
mock_create.return_value = mock_strategy
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_citation:
mock_citation.return_value = Mock(
_create_documents=Mock(), _format_sources=Mock()
)
from local_deep_research.search_system import (
AdvancedSearchSystem,
)
system = AdvancedSearchSystem(
llm=mock_llm,
search=mock_search_engine,
strategy_name="standard",
)
system.all_links_of_system = []
system.analyze_topic("test query")
assert len(system.all_links_of_system) == 1000
def test_link_dedup_preserves_order(self, mock_llm, mock_search_engine):
"""Link order preserved after dedup."""
mock_strategy = MagicMock()
ordered_links = [
{"title": "First", "url": "http://first.com"},
{"title": "Second", "url": "http://second.com"},
{"title": "Third", "url": "http://third.com"},
]
mock_strategy.all_links_of_system = ordered_links
mock_strategy.questions_by_iteration = []
mock_strategy.analyze_topic.return_value = {
"current_knowledge": "test",
"query": "test query",
}
with patch(
"local_deep_research.search_system_factory.create_strategy"
) as mock_create:
mock_create.return_value = mock_strategy
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_citation:
mock_citation.return_value = Mock(
_create_documents=Mock(), _format_sources=Mock()
)
from local_deep_research.search_system import (
AdvancedSearchSystem,
)
system = AdvancedSearchSystem(
llm=mock_llm,
search=mock_search_engine,
strategy_name="standard",
)
system.all_links_of_system = []
system.analyze_topic("test query")
assert system.all_links_of_system[0]["title"] == "First"
assert system.all_links_of_system[1]["title"] == "Second"
assert system.all_links_of_system[2]["title"] == "Third"
class TestSettingsExtraction:
"""Tests for settings extraction from snapshot."""
@pytest.fixture
def mock_llm(self):
"""Create a mock LLM."""
mock = MagicMock()
mock.invoke.return_value = MagicMock(content="test response")
return mock
@pytest.fixture
def mock_search_engine(self):
"""Create a mock search engine."""
mock = MagicMock()
mock.run.return_value = []
return mock
def test_settings_dict_value_format(self, mock_llm, mock_search_engine):
"""{'value': 'actual'} extracts correctly."""
settings = {
"search.iterations": {"value": 5},
"search.questions_per_iteration": {"value": 4},
}
with patch(
"local_deep_research.search_system_factory.create_strategy"
) as mock_create:
mock_strategy = MagicMock()
mock_strategy.questions_by_iteration = []
mock_strategy.all_links_of_system = []
mock_create.return_value = mock_strategy
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_citation:
mock_citation.return_value = Mock(
_create_documents=Mock(), _format_sources=Mock()
)
from local_deep_research.search_system import (
AdvancedSearchSystem,
)
system = AdvancedSearchSystem(
llm=mock_llm,
search=mock_search_engine,
strategy_name="standard",
settings_snapshot=settings,
)
assert system.max_iterations == 5
assert system.questions_per_iteration == 4
def test_settings_direct_value_format(self, mock_llm, mock_search_engine):
"""Direct values work."""
settings = {
"search.iterations": 3,
"search.questions_per_iteration": 2,
}
with patch(
"local_deep_research.search_system_factory.create_strategy"
) as mock_create:
mock_strategy = MagicMock()
mock_strategy.questions_by_iteration = []
mock_strategy.all_links_of_system = []
mock_create.return_value = mock_strategy
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_citation:
mock_citation.return_value = Mock(
_create_documents=Mock(), _format_sources=Mock()
)
from local_deep_research.search_system import (
AdvancedSearchSystem,
)
system = AdvancedSearchSystem(
llm=mock_llm,
search=mock_search_engine,
strategy_name="standard",
settings_snapshot=settings,
)
assert system.max_iterations == 3
assert system.questions_per_iteration == 2
def test_missing_settings_use_defaults(self, mock_llm, mock_search_engine):
"""Missing settings get defaults."""
settings = {} # Empty settings
with patch(
"local_deep_research.search_system_factory.create_strategy"
) as mock_create:
mock_strategy = MagicMock()
mock_strategy.questions_by_iteration = []
mock_strategy.all_links_of_system = []
mock_create.return_value = mock_strategy
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_citation:
mock_citation.return_value = Mock(
_create_documents=Mock(), _format_sources=Mock()
)
from local_deep_research.search_system import (
AdvancedSearchSystem,
)
system = AdvancedSearchSystem(
llm=mock_llm,
search=mock_search_engine,
strategy_name="standard",
settings_snapshot=settings,
)
# Defaults: iterations=1, questions_per_iteration=3
assert system.max_iterations == 1
assert system.questions_per_iteration == 3
def test_partial_settings_snapshot(self, mock_llm, mock_search_engine):
"""Some present, some missing."""
settings = {
"search.iterations": {"value": 7},
# questions_per_iteration is missing
}
with patch(
"local_deep_research.search_system_factory.create_strategy"
) as mock_create:
mock_strategy = MagicMock()
mock_strategy.questions_by_iteration = []
mock_strategy.all_links_of_system = []
mock_create.return_value = mock_strategy
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_citation:
mock_citation.return_value = Mock(
_create_documents=Mock(), _format_sources=Mock()
)
from local_deep_research.search_system import (
AdvancedSearchSystem,
)
system = AdvancedSearchSystem(
llm=mock_llm,
search=mock_search_engine,
strategy_name="standard",
settings_snapshot=settings,
)
assert system.max_iterations == 7
assert system.questions_per_iteration == 3 # Default
def test_nested_settings_structure(self, mock_llm, mock_search_engine):
"""Deeply nested dicts."""
# The code only checks for {'value': ...} at one level
settings = {
"search.iterations": {"value": 2, "extra": {"nested": "data"}},
}
with patch(
"local_deep_research.search_system_factory.create_strategy"
) as mock_create:
mock_strategy = MagicMock()
mock_strategy.questions_by_iteration = []
mock_strategy.all_links_of_system = []
mock_create.return_value = mock_strategy
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_citation:
mock_citation.return_value = Mock(
_create_documents=Mock(), _format_sources=Mock()
)
from local_deep_research.search_system import (
AdvancedSearchSystem,
)
system = AdvancedSearchSystem(
llm=mock_llm,
search=mock_search_engine,
strategy_name="standard",
settings_snapshot=settings,
)
assert system.max_iterations == 2
def test_none_settings_snapshot(self, mock_llm, mock_search_engine):
"""None snapshot uses all defaults."""
with patch(
"local_deep_research.search_system_factory.create_strategy"
) as mock_create:
mock_strategy = MagicMock()
mock_strategy.questions_by_iteration = []
mock_strategy.all_links_of_system = []
mock_create.return_value = mock_strategy
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_citation:
mock_citation.return_value = Mock(
_create_documents=Mock(), _format_sources=Mock()
)
from local_deep_research.search_system import (
AdvancedSearchSystem,
)
system = AdvancedSearchSystem(
llm=mock_llm,
search=mock_search_engine,
strategy_name="standard",
settings_snapshot=None,
)
assert system.max_iterations == 1
assert system.questions_per_iteration == 3
def test_empty_settings_snapshot(self, mock_llm, mock_search_engine):
"""Empty dict uses all defaults."""
with patch(
"local_deep_research.search_system_factory.create_strategy"
) as mock_create:
mock_strategy = MagicMock()
mock_strategy.questions_by_iteration = []
mock_strategy.all_links_of_system = []
mock_create.return_value = mock_strategy
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_citation:
mock_citation.return_value = Mock(
_create_documents=Mock(), _format_sources=Mock()
)
from local_deep_research.search_system import (
AdvancedSearchSystem,
)
system = AdvancedSearchSystem(
llm=mock_llm,
search=mock_search_engine,
strategy_name="standard",
settings_snapshot={},
)
assert system.max_iterations == 1
assert system.questions_per_iteration == 3
class TestProgressCallback:
"""Tests for progress callback functionality."""
@pytest.fixture
def mock_llm(self):
"""Create a mock LLM."""
mock = MagicMock()
return mock
@pytest.fixture
def mock_search_engine(self):
"""Create a mock search engine."""
return MagicMock()
def test_progress_callback_set_on_strategy(
self, mock_llm, mock_search_engine
):
"""Progress callback is set on strategy."""
with patch(
"local_deep_research.search_system_factory.create_strategy"
) as mock_create:
mock_strategy = MagicMock()
mock_strategy.questions_by_iteration = []
mock_strategy.all_links_of_system = []
mock_create.return_value = mock_strategy
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_citation:
mock_citation.return_value = Mock(
_create_documents=Mock(), _format_sources=Mock()
)
from local_deep_research.search_system import (
AdvancedSearchSystem,
)
system = AdvancedSearchSystem(
llm=mock_llm,
search=mock_search_engine,
strategy_name="standard",
)
callback = MagicMock()
system.set_progress_callback(callback)
mock_strategy.set_progress_callback.assert_called_with(callback)
def test_progress_callback_receives_updates(
self, mock_llm, mock_search_engine
):
"""Progress callback receives progress updates during search."""
with patch(
"local_deep_research.search_system_factory.create_strategy"
) as mock_create:
mock_strategy = MagicMock()
mock_strategy.all_links_of_system = []
mock_strategy.questions_by_iteration = []
mock_strategy.analyze_topic.return_value = {
"current_knowledge": "test",
"query": "test query",
}
mock_create.return_value = mock_strategy
with patch(
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
) as mock_citation:
mock_citation.return_value = Mock(
_create_documents=Mock(), _format_sources=Mock()
)
from local_deep_research.search_system import (
AdvancedSearchSystem,
)
system = AdvancedSearchSystem(
llm=mock_llm,
search=mock_search_engine,
strategy_name="standard",
settings_snapshot={
"llm.provider": {"value": "test_provider"},
"llm.model": {"value": "test_model"},
"search.tool": {"value": "test_tool"},
},
)
callback = MagicMock()
system.set_progress_callback(callback)
system.analyze_topic("test query")
# Callback should have been called during search
assert callback.called
@@ -0,0 +1,93 @@
"""Tests for the run-snapshot username injection in AdvancedSearchSystem.
The LangGraph agent re-instantiates the search engine *per tool call* from the
run's ``settings_snapshot``; registering a user's document collections needs
the username, which collection registration reads only from
``settings_snapshot["_username"]``. ``AdvancedSearchSystem.__init__`` injects it
— it is the single consumer of every strategy-running path (web run,
programmatic API, benchmarks) — so a collection/library primary works for all
of them. Regression: a collection primary previously failed inside the agent
with "Unknown search engine 'collection_…'".
"""
from unittest.mock import Mock, patch
from local_deep_research.search_system import (
AdvancedSearchSystem,
_ensure_snapshot_username,
)
class TestEnsureSnapshotUsername:
"""Unit contract of the injection helper."""
def test_injects_username_when_missing(self):
snap = {"search.tool": "collection_abc"}
result = _ensure_snapshot_username(snap, "alice")
assert result["_username"] == "alice"
# search.tool (and everything else) is preserved.
assert result["search.tool"] == "collection_abc"
def test_does_not_mutate_caller_dict(self):
snap = {"search.tool": "collection_abc"}
result = _ensure_snapshot_username(snap, "alice")
assert "_username" not in snap # original untouched
assert result is not snap
def test_preserves_existing_username(self):
snap = {"_username": "already", "search.tool": "x"}
result = _ensure_snapshot_username(snap, "alice")
# An explicit value is never overwritten, and no copy is made.
assert result is snap
assert result["_username"] == "already"
def test_noop_when_username_none(self):
snap = {"search.tool": "x"}
result = _ensure_snapshot_username(snap, None)
assert result is snap
assert "_username" not in result
def test_noop_when_username_empty_string(self):
snap = {"search.tool": "x"}
result = _ensure_snapshot_username(snap, "")
assert result is snap
assert "_username" not in result
def test_noop_when_snapshot_not_dict(self):
# Defensive: a non-dict snapshot is returned unchanged, never crashes.
assert _ensure_snapshot_username(None, "alice") is None
class TestAdvancedSearchSystemInjectsUsername:
"""Locks the call site: constructing the system with a username must put it
into the snapshot the strategy (and its per-call engine creation) reads.
``create_strategy`` is patched so the assertion is about the injection, not
strategy construction details.
"""
def test_init_injects_username_into_snapshot(self):
with patch(
"local_deep_research.search_system_factory.create_strategy",
return_value=Mock(),
):
system = AdvancedSearchSystem(
llm=Mock(),
search=Mock(),
settings_snapshot={"search.tool": "collection_abc"},
username="alice",
)
assert system.settings_snapshot["_username"] == "alice"
# The configured primary is preserved alongside the injected username.
assert system.settings_snapshot["search.tool"] == "collection_abc"
def test_init_without_username_leaves_snapshot_clean(self):
with patch(
"local_deep_research.search_system_factory.create_strategy",
return_value=Mock(),
):
system = AdvancedSearchSystem(
llm=Mock(),
search=Mock(),
settings_snapshot={"search.tool": "searxng"},
)
assert "_username" not in system.settings_snapshot