Files
wehub-resource-sync 7a0da7932b
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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

350 lines
13 KiB
Python

"""
Tests for run_research_process() core execution logic.
Covers:
- Quick vs detailed mode branching
- Settings context creation
- Progress callback invocation
- Termination handling
- Error handling
- Research status updates
"""
import pytest
from local_deep_research.web.services.research_service import (
_DETAILED_REPORT_PROGRESS_END,
_DETAILED_SEARCH_PROGRESS_CAP,
_REPORT_PHASES,
)
class TestSettingsContext:
"""Tests for SettingsContext created inside run_research_process."""
def test_settings_context_extracts_values_from_setting_objects(self):
"""SettingsContext extracts 'value' from setting dicts."""
# Replicate the SettingsContext class logic from research_service.py
snapshot = {
"llm.provider": {"value": "openai", "type": "string"},
"search.tool": "google", # plain value
}
# Simulate the extraction logic
values = {}
for key, setting in snapshot.items():
if isinstance(setting, dict) and "value" in setting:
values[key] = setting["value"]
else:
values[key] = setting
assert values["llm.provider"] == "openai"
assert values["search.tool"] == "google"
def test_settings_context_get_setting_from_snapshot(self):
"""get_setting returns value from snapshot, default for missing."""
values = {"llm.provider": "openai"}
def get_setting(key, default=None):
return values.get(key, default)
assert get_setting("llm.provider") == "openai"
assert get_setting("missing.key", "fallback") == "fallback"
def test_settings_context_empty_snapshot(self):
"""Empty snapshot → all get_setting calls return default."""
values = {}
def get_setting(key, default=None):
return values.get(key, default)
assert get_setting("any.key", 42) == 42
class TestProgressCallback:
"""Tests for progress callback logic."""
def test_progress_adjusted_for_detailed_output_generation(self):
"""Detailed mode output_generation → capped at search cap."""
mode = "detailed"
metadata = {"phase": "output_generation"}
progress_percent = 90
adjusted_progress = progress_percent
phase = metadata.get("phase", "")
if mode == "detailed":
if phase not in _REPORT_PHASES and progress_percent is not None:
adjusted_progress = min(
_DETAILED_SEARCH_PROGRESS_CAP, progress_percent
)
assert adjusted_progress == _DETAILED_SEARCH_PROGRESS_CAP
def test_progress_adjusted_for_detailed_report_generation(self):
"""Detailed mode report_generation → passes through (wrapper maps range)."""
mode = "detailed"
metadata = {"phase": "report_generation"}
progress_percent = 50
adjusted_progress = progress_percent
phase = metadata.get("phase", "")
if mode == "detailed" and phase not in _REPORT_PHASES:
adjusted_progress = min(
_DETAILED_SEARCH_PROGRESS_CAP, progress_percent
)
assert adjusted_progress == 50
def test_progress_adjusted_for_quick_output_generation(self):
"""Quick mode output_generation → scaled 85-95%."""
mode = "quick"
metadata = {"phase": "output_generation"}
progress_percent = 50
adjusted_progress = progress_percent
if (
mode == "quick"
and metadata.get("phase") == "output_generation"
and progress_percent is not None
):
if progress_percent > 0:
adjusted_progress = 85 + (progress_percent / 100) * 10
else:
adjusted_progress = 85
assert adjusted_progress == 90.0
def test_quick_output_generation_with_none_progress_passes_through(self):
"""Quick mode output_generation with None progress must not crash."""
mode = "quick"
metadata = {"phase": "output_generation"}
progress_percent = None
adjusted_progress = progress_percent
if (
mode == "quick"
and metadata.get("phase") == "output_generation"
and progress_percent is not None
):
if progress_percent > 0:
adjusted_progress = 85 + (progress_percent / 100) * 10
else:
adjusted_progress = 85
assert adjusted_progress is None
def test_search_plan_extracted_from_message(self):
"""SEARCH_PLAN: in message → engines extracted."""
message = "Planning SEARCH_PLAN: google, bing, wikipedia"
metadata = {}
if "SEARCH_PLAN:" in message:
engines = message.split("SEARCH_PLAN:")[1].strip()
metadata["planned_engines"] = engines
metadata["phase"] = "search_planning"
assert metadata["planned_engines"] == "google, bing, wikipedia"
assert metadata["phase"] == "search_planning"
def test_engine_selected_extracted_from_message(self):
"""ENGINE_SELECTED: in message → engine extracted."""
message = "Selected ENGINE_SELECTED: google"
metadata = {}
if "ENGINE_SELECTED:" in message:
engine = message.split("ENGINE_SELECTED:")[1].strip()
metadata["selected_engine"] = engine
metadata["phase"] = "search"
assert metadata["selected_engine"] == "google"
def _apply_detailed_progress(progress_percent, phase):
"""Replicate detailed-mode progress adjustment from research_service.py.
Mirrors the production logic so tests stay self-contained, matching the
existing pattern in this file.
"""
adjusted_progress = progress_percent
if phase not in _REPORT_PHASES and progress_percent is not None:
adjusted_progress = min(_DETAILED_SEARCH_PROGRESS_CAP, progress_percent)
return adjusted_progress
class TestDetailedModeSearchCap:
"""Tests for the search-phase cap in detailed mode.
Global monotonicity is enforced by update_progress_and_check_active in
web/routes/globals.py; these tests cover only the closure's per-call
capping. The integration tests in
test_research_service_progress_integration.py exercise the full
closure + global guard pipeline.
"""
def test_high_search_values_clamp_to_cap(self):
"""Search progress above the cap clamps to the cap."""
emitted = [
_apply_detailed_progress(value, "search")
for value in (90, 33, 50, 5, 100)
]
assert emitted == [8, 8, 8, 5, 8]
def test_low_search_values_pass_through_below_cap(self):
"""Search progress below the cap passes through unchanged."""
assert _apply_detailed_progress(3, "search") == 3
assert _apply_detailed_progress(7, "search") == 7
assert _apply_detailed_progress(8, "search") == 8
def test_none_progress_passes_through_for_error_phase(self):
"""None progress with phase='error' must not crash (regression #3806)."""
assert _apply_detailed_progress(None, "error") is None
def test_none_progress_passes_through_for_sub_search(self):
"""None progress from constrained-search sub-callback must not crash.
The sub-callback in constrained_search_strategy.py emits None with
phase in {'search_complete', 'final_results'}.
"""
for phase in ("search_complete", "final_results"):
assert _apply_detailed_progress(None, phase) is None, (
f"None should pass through for phase={phase}"
)
def test_report_phase_unaffected_by_search_cap(self):
"""Report phases pass through; the wrapper already maps the range."""
for phase in (
"report_generation",
"report_section_research",
"report_formatting",
"report_structure",
"report_complete",
):
assert _apply_detailed_progress(55, phase) == 55
def test_strategy_complete_phase_does_not_jump_bar_to_100_mid_report(self):
"""phase='complete' from a strategy mid-report must NOT pin bar to 100.
Regression: every search strategy emits {'phase': 'complete'} when its
analyze_topic finishes (e.g. standard_strategy.py:334 at value 95).
report_generator runs analyze_topic per subsection via
self.search_system.analyze_topic, and the SearchSystem's
progress_callback is the outer callback (set at research_service.py:711,
NOT the wrapper). So a strategy 'complete' fires AFTER each subsection
and would jump the bar to 100 mid-report if treated as the final marker.
Expected behavior: 'complete' is treated as a search-phase emission —
capped at the search cap. The legitimate 100% emit uses phase=
'report_complete' (in _REPORT_PHASES) and passes through.
"""
# First subsection's strategy finishes: emits phase='complete' at 95
adjusted = _apply_detailed_progress(95, "complete")
assert adjusted != 100, (
"regression: strategy 'complete' jumped bar to 100 mid-report"
)
assert adjusted == _DETAILED_SEARCH_PROGRESS_CAP
def test_legitimate_final_complete_uses_report_complete_phase(self):
"""The legitimate end-of-research 100 emit uses phase='report_complete'.
Confirms that the production code's final detailed-mode emission
uses a phase that's in _REPORT_PHASES, so it passes through
unchanged to 100 — independent of the strategy-level 'complete'
phase that is intentionally capped.
"""
assert "report_complete" in _REPORT_PHASES
assert "complete" not in _REPORT_PHASES
adjusted = _apply_detailed_progress(
_DETAILED_REPORT_PROGRESS_END, "report_complete"
)
assert adjusted == _DETAILED_REPORT_PROGRESS_END
class TestTerminationHandling:
"""Tests for termination checks in research process."""
def test_termination_during_progress_raises(self):
"""Termination requested during progress → raises exception."""
from local_deep_research.web.services.research_service import (
ResearchTerminatedException,
)
with pytest.raises(ResearchTerminatedException):
raise ResearchTerminatedException("Research was terminated by user")
def _classify_error(error_message):
"""Replicate error classification logic from run_research_process (lines 714-733)."""
if "status code: 503" in error_message:
return "ollama_unavailable"
if "status code: 404" in error_message:
return "model_not_found"
if "status code:" in error_message:
return "api_error"
if "connection" in error_message.lower():
return "connection_error"
return "unknown"
class TestErrorClassification:
"""Tests for search error classification in run_research_process."""
@pytest.mark.parametrize(
"error_message, expected_type",
[
("Request failed with status code: 503", "ollama_unavailable"),
("status code: 404 not found", "model_not_found"),
("status code: 429 rate limited", "api_error"),
("status code: 500 internal error", "api_error"),
("Connection refused to localhost:11434", "connection_error"),
("TCP connection reset by peer", "connection_error"),
("Something unexpected happened", "unknown"),
("", "unknown"),
],
)
def test_error_classification(self, error_message, expected_type):
assert _classify_error(error_message) == expected_type
def test_503_takes_priority_over_generic_status_code(self):
"""503 is matched specifically before generic 'status code:' pattern."""
assert _classify_error("status code: 503") == "ollama_unavailable"
def test_404_takes_priority_over_generic_status_code(self):
"""404 is matched specifically before generic 'status code:' pattern."""
assert _classify_error("status code: 404") == "model_not_found"
class TestResearchModes:
"""Tests for mode-specific behavior."""
def test_quick_mode_checks_findings(self):
"""Quick mode checks for findings or formatted_findings."""
results = {"findings": ["f1"], "formatted_findings": "Summary text"}
mode = "quick"
if mode == "quick":
has_findings = bool(
results.get("findings") or results.get("formatted_findings")
)
else:
has_findings = False
assert has_findings is True
def test_quick_mode_detects_error_in_findings(self):
"""Quick mode detects 'Error:' prefix in formatted_findings."""
results = {"formatted_findings": "Error: token limit exceeded"}
raw = results["formatted_findings"]
is_error = isinstance(raw, str) and raw.startswith("Error:")
assert is_error is True
def test_quick_mode_no_error_prefix(self):
"""Normal formatted_findings → not detected as error."""
results = {"formatted_findings": "This is a normal summary."}
raw = results["formatted_findings"]
is_error = isinstance(raw, str) and raw.startswith("Error:")
assert is_error is False