Files
searxng-use-cli/tests/test_research_mode.py
thzxx 0c8fdc1e45 fix(v2.1.1): 修复执行问题记录中的真实 bug + 文档对齐
源码修复(5 项):
1. search.py --time-range choices 加入 week(对齐 SearXNG API 四档)
2. fetch.py stdlib 路径处理 gzip/deflate 解压(被沙箱伪响应掩盖的真实 bug,
   无 requests 环境抓取压缩服务器会全页 U+FFFD 乱码)
3. search.py --research 模式实现跨角度合并去重,输出 merged_results 字段
   (兑现文档承诺 "Results are merged and deduplicated")
4. search.py fetch_page 返回 error_code 字段 + AdaptiveThrottle 用
   E_RATE_LIMIT 结构化检测 429(原字符串匹配 "429" 会漏判
   "Too Many Requests")
5. search.py _retry_with_backoff 复用 compute_backoff_delay(60s 封顶)
   + 处理 Retry-After header,与 fetch.py 保持一致

增强(3 项):
- common.py 精确化 baidu 子域列表(pan.baidu.com/cloud.baidu.com 不再误伤)
- search.py expand_research_queries 根据主题语言切换中英文后缀
- search.py 新增 _warn_unresponsive_engines,识别实例侧引擎挂起并提示

文档/版本:
- _config.py VERSION 2.1.0 → 2.1.1
- SKILL.md 同步更新(time-range week、merged_results、error_code、baidu 精确化)
- README.md 同步更新 + 测试数量 503 → 539

测试: 539 个全部通过,含 6 个新增验证测试
2026-08-03 12:54:27 +08:00

148 lines
5.5 KiB
Python

"""Tests for v2.1.0 --research mode.
Covers:
* expand_research_queries — topic → [(angle, query), ...] expansion
* --research CLI flag — mutex with --query/--queries-file, --stream
* --research end-to-end — subprocess with real CLI (stubbed at search_multi)
"""
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from search import expand_research_queries, _RESEARCH_ANGLES
PROJECT_ROOT = Path(__file__).resolve().parent.parent
# ===== expand_research_queries =====
class TestExpandResearchQueries:
def test_basic_topic(self):
queries = expand_research_queries("Python")
assert len(queries) == 5
# First angle is overview (no suffix)
assert queries[0] == ("overview", "Python")
# Each query starts with the topic
for angle, q in queries:
assert q.startswith("Python")
def test_chinese_topic(self):
queries = expand_research_queries("七森莉莉")
assert len(queries) == 5
assert queries[0] == ("overview", "七森莉莉")
assert queries[1] == ("profile", "七森莉莉 简介")
assert queries[2] == ("background", "七森莉莉 经历")
assert queries[3] == ("works", "七森莉莉 作品")
assert queries[4] == ("review", "七森莉莉 评价")
def test_angles_match_definition(self):
queries = expand_research_queries("test")
angles = [a for a, _ in queries]
assert angles == [a for a, _, _ in _RESEARCH_ANGLES]
def test_empty_topic_returns_empty(self):
assert expand_research_queries("") == []
def test_whitespace_topic_returns_empty(self):
assert expand_research_queries(" ") == []
def test_topic_is_stripped(self):
queries = expand_research_queries(" Python ")
assert queries[0] == ("overview", "Python")
def test_deterministic(self):
"""Same topic always produces same queries (no randomness)."""
q1 = expand_research_queries("Rust async")
q2 = expand_research_queries("Rust async")
assert q1 == q2
def test_multi_word_topic(self):
queries = expand_research_queries("Python asyncio tutorial")
assert queries[0] == ("overview", "Python asyncio tutorial")
# v2.1.0:英文主题用英文后缀(避免跨语言组合匹配度低)
assert "profile" in queries[1][1]
# ===== --research CLI mutex checks =====
def _run_cli(*args, env=None):
"""Run scripts/search.py as a real subprocess."""
full_env = {**os.environ, **(env or {})}
cmd = [sys.executable, str(PROJECT_ROOT / "scripts" / "search.py")] + list(args)
return subprocess.run(
cmd, cwd=str(PROJECT_ROOT),
capture_output=True, text=True, encoding="utf-8", timeout=30,
env=full_env,
)
class TestResearchCLIMutex:
def test_research_with_query_errors(self):
"""--research + --query → E_INPUT error."""
r = _run_cli("--research", "topic", "-q", "query", "-i", "https://x.com")
assert r.returncode != 0
# Error goes to stdout in json mode, stderr in other modes
combined = r.stdout + r.stderr
assert "--research cannot be used with --query" in combined
def test_research_with_queries_file_errors(self, tmp_path):
"""--research + --queries-file → E_INPUT error."""
qf = tmp_path / "queries.txt"
qf.write_text("test\n", encoding="utf-8")
r = _run_cli("--research", "topic", "--queries-file", str(qf),
"-i", "https://x.com")
assert r.returncode != 0
combined = r.stdout + r.stderr
assert "--research cannot be used with --queries-file" in combined
def test_research_with_stream_errors(self):
"""--research + --stream → E_INPUT error."""
r = _run_cli("--research", "topic", "--stream", "-i", "https://x.com")
assert r.returncode != 0
combined = r.stdout + r.stderr
assert "--stream cannot be used with --research" in combined
def test_research_alone_without_instance_errors(self):
"""--research without -i or config → E_CONFIG (instance required).
Uses SEARXNG_INSTANCE= (empty) + a non-existent HOME to prevent
config file auto-discovery from finding the project's searxng.toml.
"""
r = _run_cli("--research", "topic",
env={"SEARXNG_INSTANCE": "",
"USERPROFILE": "/nonexistent",
"HOME": "/nonexistent"})
assert r.returncode != 0
# ===== --research end-to-end (stubbed) =====
class TestResearchExpandOutput:
"""Verify that expand_research_queries produces the expected output shape
that the CLI relies on."""
def test_output_is_list_of_tuples(self):
queries = expand_research_queries("test")
assert isinstance(queries, list)
for item in queries:
assert isinstance(item, tuple)
assert len(item) == 2
assert isinstance(item[0], str) # angle
assert isinstance(item[1], str) # query
def test_all_angles_present(self):
queries = expand_research_queries("test")
angles = {a for a, _ in queries}
assert angles == {"overview", "profile", "background", "works", "review"}
def test_overview_has_no_suffix(self):
"""The overview angle should be just the topic itself."""
queries = expand_research_queries("my topic")
assert queries[0][1] == "my topic"