核心新增(面向 AI Agent 程序化使用): - 结构化错误码体系:E_CONFIG/E_AUTH/E_NETWORK/E_RATE_LIMIT/E_PARSE/E_EMPTY/E_INPUT/E_INTERNAL classify_error() 自动分类异常,JSON 错误输出含 error_code 字段 - JSON Lines 流式输出 (--stream):每条结果独立一行,AI 可增量处理 - 进度事件 (--progress):JSON Lines 事件流到 stderr(start/cache_hit/fetch_ok/done 等) 测试补全(+154 例,覆盖全部高风险盲区): - HTML 回退搜索路径 (19) - --fetch 自动抓取 (21) - --verify 健康检查 (15) - 输出格式化 (15) - 实例解析链 (20) - 并行多实例搜索 (10) - CLI 入口与端到端 (17) - 错误码分类 (27) - 流式输出与进度事件 (10) 源码改进: - search.py: h3 内 a 标签 href 作为 url fallback,提升 SearXNG 主题兼容性 - common.py: 新增 classify_error/emit_progress/set_progress_enabled 文档同步:SKILL.md 新增 AI Agent Integration Guide 章节,README.md 更新参数与错误码表
188 lines
6.2 KiB
Python
188 lines
6.2 KiB
Python
"""Tests for scripts/search.py — output formatting functions.
|
|
|
|
Pure-function tests (no network, no mocking) covering:
|
|
* format_brief — title/url/content rendering, snippet truncation,
|
|
suggestions, answers, empty input
|
|
* format_urls — basic list, skip-empty-url, empty input
|
|
* _format_results — json/urls/brief/csv dispatch + fetched-pages section
|
|
"""
|
|
import json
|
|
import types
|
|
|
|
from search import format_brief, format_urls, _format_results
|
|
|
|
|
|
# ----- format_brief -----
|
|
|
|
def test_format_brief_basic():
|
|
"""Basic brief output includes title, URL, and content lines."""
|
|
results = {"results": [
|
|
{"title": "Hello World", "url": "https://example.com/1",
|
|
"content": "A short snippet."},
|
|
]}
|
|
out = format_brief(results)
|
|
assert "1. Hello World" in out
|
|
assert "https://example.com/1" in out
|
|
assert "A short snippet." in out
|
|
|
|
|
|
def test_format_brief_no_content():
|
|
"""When content is empty/missing, no content line is emitted."""
|
|
results = {"results": [
|
|
{"title": "No Snippet", "url": "https://example.com/2"},
|
|
]}
|
|
out = format_brief(results)
|
|
assert "1. No Snippet" in out
|
|
assert "https://example.com/2" in out
|
|
# Only title + url lines plus a trailing blank line — no content line.
|
|
lines = out.split("\n")
|
|
assert len(lines) == 3
|
|
assert lines[2] == ""
|
|
|
|
|
|
def test_format_brief_snippet_truncation():
|
|
"""snippet_len > 0 truncates content to that many characters."""
|
|
long_text = "abcdefghijklmnopqrstuvwxyz"
|
|
results = {"results": [
|
|
{"title": "T", "url": "https://example.com", "content": long_text},
|
|
]}
|
|
out = format_brief(results, snippet_len=5)
|
|
assert "abcde" in out
|
|
assert "abcdef" not in out # truncated beyond 5 chars
|
|
|
|
|
|
def test_format_brief_with_suggestions():
|
|
"""Suggestions list is rendered as a comma-separated line."""
|
|
results = {
|
|
"results": [{"title": "T", "url": "https://example.com", "content": "c"}],
|
|
"suggestions": ["python asyncio", "python requests"],
|
|
}
|
|
out = format_brief(results)
|
|
assert "Suggestions: python asyncio, python requests" in out
|
|
|
|
|
|
def test_format_brief_with_answers():
|
|
"""Each answer is emitted on its own 'Answer:' line."""
|
|
results = {
|
|
"results": [{"title": "T", "url": "https://example.com", "content": "c"}],
|
|
"answers": ["42", "the answer"],
|
|
}
|
|
out = format_brief(results)
|
|
assert "Answer: 42" in out
|
|
assert "Answer: the answer" in out
|
|
|
|
|
|
def test_format_brief_empty_results():
|
|
"""Empty results list (or missing key) yields an empty string."""
|
|
assert format_brief({"results": []}) == ""
|
|
assert format_brief({}) == ""
|
|
|
|
|
|
# ----- format_urls -----
|
|
|
|
def test_format_urls_basic():
|
|
"""Each result URL appears on its own line."""
|
|
results = {"results": [
|
|
{"url": "https://a.com/1"},
|
|
{"url": "https://b.com/2"},
|
|
]}
|
|
out = format_urls(results)
|
|
assert out == "https://a.com/1\nhttps://b.com/2"
|
|
|
|
|
|
def test_format_urls_skips_empty():
|
|
"""Results with empty/missing URLs are skipped."""
|
|
results = {"results": [
|
|
{"url": "https://a.com/1"},
|
|
{"url": ""},
|
|
{"title": "no url here"},
|
|
{"url": "https://b.com/2"},
|
|
]}
|
|
out = format_urls(results)
|
|
assert out == "https://a.com/1\nhttps://b.com/2"
|
|
|
|
|
|
def test_format_urls_empty_results():
|
|
"""Empty results yield an empty string."""
|
|
assert format_urls({"results": []}) == ""
|
|
assert format_urls({}) == ""
|
|
|
|
|
|
# ----- _format_results -----
|
|
|
|
def test_format_results_json():
|
|
"""json format emits valid, parseable JSON mirroring the input."""
|
|
results = {"results": [
|
|
{"title": "T", "url": "https://example.com", "content": "c"},
|
|
], "suggestions": ["x"]}
|
|
args = types.SimpleNamespace(format="json")
|
|
out = _format_results(results, args)
|
|
parsed = json.loads(out)
|
|
assert parsed == results
|
|
|
|
|
|
def test_format_results_brief():
|
|
"""brief format dispatches to format_brief."""
|
|
results = {"results": [
|
|
{"title": "T", "url": "https://example.com", "content": "snippet"},
|
|
]}
|
|
args = types.SimpleNamespace(format="brief", snippet_len=0, fetch=0)
|
|
out = _format_results(results, args)
|
|
assert "1. T" in out
|
|
assert "https://example.com" in out
|
|
assert "snippet" in out
|
|
|
|
|
|
def test_format_results_urls():
|
|
"""urls format dispatches to format_urls."""
|
|
results = {"results": [
|
|
{"url": "https://a.com/1"},
|
|
{"url": "https://b.com/2"},
|
|
]}
|
|
args = types.SimpleNamespace(format="urls")
|
|
out = _format_results(results, args)
|
|
assert out == "https://a.com/1\nhttps://b.com/2"
|
|
|
|
|
|
def test_format_results_csv_smoke():
|
|
"""csv format emits the header row + one row per result (smoke test)."""
|
|
results = {"results": [
|
|
{"title": "T1", "url": "https://a.com", "engine": "google",
|
|
"score": 1.0, "published_date": "2024-01-01", "content": "snip"},
|
|
]}
|
|
args = types.SimpleNamespace(format="csv", snippet_len=0, fetch=0)
|
|
out = _format_results(results, args)
|
|
assert "title,url,engine,score,published_date,content" in out
|
|
assert "T1" in out
|
|
assert "https://a.com" in out
|
|
|
|
|
|
def test_format_results_brief_with_fetched():
|
|
"""brief + args.fetch>0 + results['fetched'] appends a fetched section."""
|
|
results = {
|
|
"results": [{"title": "T", "url": "https://example.com", "content": "c"}],
|
|
"fetched": [
|
|
{"url": "https://example.com", "status": "ok",
|
|
"text": "Full page content here."},
|
|
],
|
|
}
|
|
args = types.SimpleNamespace(format="brief", snippet_len=0, fetch=3)
|
|
out = _format_results(results, args)
|
|
assert "FETCHED PAGES (1 pages)" in out
|
|
assert "--- https://example.com ---" in out
|
|
assert "Full page content here." in out
|
|
|
|
|
|
def test_format_results_brief_with_fetched_error():
|
|
"""Fetched entries with status != 'ok' render an [ERROR: ...] line."""
|
|
results = {
|
|
"results": [{"title": "T", "url": "https://example.com", "content": "c"}],
|
|
"fetched": [
|
|
{"url": "https://broken.example", "status": "error", "error": "timeout"},
|
|
],
|
|
}
|
|
args = types.SimpleNamespace(format="brief", snippet_len=0, fetch=3)
|
|
out = _format_results(results, args)
|
|
assert "FETCHED PAGES (1 pages)" in out
|
|
assert "[ERROR: timeout]" in out
|