feat(v1.7.0): AI 友好度增强 + 测试补全 (155→309)
核心新增(面向 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 更新参数与错误码表
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
"""Tests for structured error classification (error code system).
|
||||
|
||||
Covers: classify_error() mapping exceptions to E_* codes, _emit_error()
|
||||
outputting error_code in JSON mode, _run_single_query() propagating
|
||||
error_code from caught exceptions.
|
||||
"""
|
||||
import json
|
||||
import urllib.error
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from common import (
|
||||
classify_error,
|
||||
E_CONFIG, E_AUTH, E_NETWORK, E_RATE_LIMIT,
|
||||
E_PARSE, E_EMPTY, E_INPUT, E_INTERNAL,
|
||||
)
|
||||
from search import _emit_error, _run_single_query
|
||||
|
||||
|
||||
# ----- classify_error: HTTP errors -----
|
||||
|
||||
def test_classify_429_is_rate_limit():
|
||||
err = urllib.error.HTTPError("url", 429, "Too Many Requests", {}, None)
|
||||
assert classify_error(err) == E_RATE_LIMIT
|
||||
|
||||
|
||||
def test_classify_401_is_auth():
|
||||
err = urllib.error.HTTPError("url", 401, "Unauthorized", {}, None)
|
||||
assert classify_error(err) == E_AUTH
|
||||
|
||||
|
||||
def test_classify_403_is_auth():
|
||||
err = urllib.error.HTTPError("url", 403, "Forbidden", {}, None)
|
||||
assert classify_error(err) == E_AUTH
|
||||
|
||||
|
||||
def test_classify_404_is_input():
|
||||
err = urllib.error.HTTPError("url", 404, "Not Found", {}, None)
|
||||
assert classify_error(err) == E_INPUT
|
||||
|
||||
|
||||
def test_classify_400_is_input():
|
||||
err = urllib.error.HTTPError("url", 400, "Bad Request", {}, None)
|
||||
assert classify_error(err) == E_INPUT
|
||||
|
||||
|
||||
def test_classify_500_is_network():
|
||||
err = urllib.error.HTTPError("url", 500, "Internal Server Error", {}, None)
|
||||
assert classify_error(err) == E_NETWORK
|
||||
|
||||
|
||||
def test_classify_503_is_network():
|
||||
err = urllib.error.HTTPError("url", 503, "Service Unavailable", {}, None)
|
||||
assert classify_error(err) == E_NETWORK
|
||||
|
||||
|
||||
# ----- classify_error: connection errors -----
|
||||
|
||||
def test_classify_urlerror_is_network():
|
||||
err = urllib.error.URLError("connection refused")
|
||||
assert classify_error(err) == E_NETWORK
|
||||
|
||||
|
||||
def test_classify_timeout_is_network():
|
||||
assert classify_error(TimeoutError("timed out")) == E_NETWORK
|
||||
|
||||
|
||||
def test_classify_oserror_is_network():
|
||||
assert classify_error(OSError("network unreachable")) == E_NETWORK
|
||||
|
||||
|
||||
# ----- classify_error: parse errors -----
|
||||
|
||||
def test_classify_value_error_is_parse():
|
||||
assert classify_error(ValueError("invalid JSON")) == E_PARSE
|
||||
|
||||
|
||||
def test_classify_json_decode_error_is_parse():
|
||||
import json as _json
|
||||
try:
|
||||
_json.loads("{bad}")
|
||||
assert False
|
||||
except _json.JSONDecodeError as e:
|
||||
assert classify_error(e) == E_PARSE
|
||||
|
||||
|
||||
# ----- classify_error: file/input errors -----
|
||||
|
||||
def test_classify_filenotfound_is_input():
|
||||
assert classify_error(FileNotFoundError("no such file")) == E_INPUT
|
||||
|
||||
|
||||
# ----- classify_error: RuntimeError message inference -----
|
||||
|
||||
def test_classify_runtime_all_instances_failed_is_network():
|
||||
err = RuntimeError("All 3 instances failed. Last error: timeout")
|
||||
assert classify_error(err) == E_NETWORK
|
||||
|
||||
|
||||
def test_classify_runtime_auth_message_is_auth():
|
||||
err = RuntimeError("Auth failed: 403 Forbidden")
|
||||
assert classify_error(err) == E_AUTH
|
||||
|
||||
|
||||
def test_classify_runtime_rate_limit_message():
|
||||
err = RuntimeError("Rate limited: 429")
|
||||
assert classify_error(err) == E_RATE_LIMIT
|
||||
|
||||
|
||||
def test_classify_runtime_parse_message_is_parse():
|
||||
err = RuntimeError("Failed to parse JSON response")
|
||||
assert classify_error(err) == E_PARSE
|
||||
|
||||
|
||||
def test_classify_runtime_no_instance_is_config():
|
||||
err = RuntimeError("No instance found in config")
|
||||
assert classify_error(err) == E_CONFIG
|
||||
|
||||
|
||||
def test_classify_runtime_unknown_is_internal():
|
||||
err = RuntimeError("Something unexpected happened")
|
||||
assert classify_error(err) == E_INTERNAL
|
||||
|
||||
|
||||
# ----- classify_error: fallback -----
|
||||
|
||||
def test_classify_unknown_exception_is_internal():
|
||||
assert classify_error(Exception("unknown")) == E_INTERNAL
|
||||
|
||||
|
||||
# ----- _emit_error: error_code in JSON output -----
|
||||
|
||||
def test_emit_error_json_includes_error_code():
|
||||
"""JSON mode: error_code appears in the JSON output."""
|
||||
args = SimpleNamespace(format="json")
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_emit_error("connection refused", args, query="test",
|
||||
error_code=E_NETWORK)
|
||||
assert exc_info.value.code == 1
|
||||
# Output already printed to stdout; we can't easily capture it here
|
||||
# without capsys, so we test via capsys below.
|
||||
|
||||
|
||||
def test_emit_error_json_with_code_and_query(capsys):
|
||||
"""JSON mode: both error_code and query are in the output."""
|
||||
args = SimpleNamespace(format="json")
|
||||
with pytest.raises(SystemExit):
|
||||
_emit_error("rate limited", args, query="news",
|
||||
error_code=E_RATE_LIMIT)
|
||||
out, _ = capsys.readouterr()
|
||||
data = json.loads(out)
|
||||
assert data["error"] == "rate limited"
|
||||
assert data["error_code"] == E_RATE_LIMIT
|
||||
assert data["query"] == "news"
|
||||
assert data["exit_code"] == 1
|
||||
|
||||
|
||||
def test_emit_error_json_without_error_code(capsys):
|
||||
"""JSON mode: error_code field omitted when not provided (backwards compat)."""
|
||||
args = SimpleNamespace(format="json")
|
||||
with pytest.raises(SystemExit):
|
||||
_emit_error("some error", args)
|
||||
out, _ = capsys.readouterr()
|
||||
data = json.loads(out)
|
||||
assert "error_code" not in data
|
||||
|
||||
|
||||
def test_emit_error_brief_includes_code_prefix(caplog):
|
||||
"""Non-JSON mode: error_code appears as [E_*] prefix in log output."""
|
||||
args = SimpleNamespace(format="brief")
|
||||
with pytest.raises(SystemExit):
|
||||
with caplog.at_level("ERROR"):
|
||||
_emit_error("not found", args, query="test", error_code=E_INPUT)
|
||||
assert "[E_INPUT]" in caplog.text
|
||||
|
||||
|
||||
# ----- _run_single_query: error_code propagation -----
|
||||
|
||||
def test_run_single_query_propagates_error_code():
|
||||
"""When search_multi raises, _run_single_query returns the classified code."""
|
||||
import search as search_mod
|
||||
err = urllib.error.URLError("connection refused")
|
||||
args = SimpleNamespace(
|
||||
query="test", format="json", method="GET", timeout=15, retry=0,
|
||||
serial=False, no_dedup=False, sort_by="none", max_results=None,
|
||||
include_domain=None, exclude_domain=None, fetch=0, fetch_timeout=10,
|
||||
fetch_retries=3, max_size=None, cache_ttl=0, snippet_len=0,
|
||||
categories=None, language=None, pageno=1, time_range="year",
|
||||
safesearch=0, engines="google,bing",
|
||||
)
|
||||
with patch.object(search_mod, "search_multi", side_effect=err):
|
||||
results, err_str, err_code = _run_single_query(
|
||||
"test", args, ["https://x.example.com"], {}, 0)
|
||||
assert results is None
|
||||
assert "connection refused" in err_str
|
||||
assert err_code == E_NETWORK
|
||||
|
||||
|
||||
def test_run_single_query_propagates_auth_error():
|
||||
"""HTTP 403 → error_code = E_AUTH."""
|
||||
import search as search_mod
|
||||
err = urllib.error.HTTPError("url", 403, "Forbidden", {}, None)
|
||||
args = SimpleNamespace(
|
||||
query="test", format="json", method="GET", timeout=15, retry=0,
|
||||
serial=False, no_dedup=False, sort_by="none", max_results=None,
|
||||
include_domain=None, exclude_domain=None, fetch=0, fetch_timeout=10,
|
||||
fetch_retries=3, max_size=None, cache_ttl=0, snippet_len=0,
|
||||
categories=None, language=None, pageno=1, time_range="year",
|
||||
safesearch=0, engines="google,bing",
|
||||
)
|
||||
with patch.object(search_mod, "search_multi", side_effect=err):
|
||||
_, _, err_code = _run_single_query(
|
||||
"test", args, ["https://x.example.com"], {}, 0)
|
||||
assert err_code == E_AUTH
|
||||
|
||||
|
||||
def test_run_single_query_propagates_rate_limit():
|
||||
"""HTTP 429 → error_code = E_RATE_LIMIT."""
|
||||
import search as search_mod
|
||||
err = urllib.error.HTTPError("url", 429, "Too Many Requests", {}, None)
|
||||
args = SimpleNamespace(
|
||||
query="test", format="json", method="GET", timeout=15, retry=0,
|
||||
serial=False, no_dedup=False, sort_by="none", max_results=None,
|
||||
include_domain=None, exclude_domain=None, fetch=0, fetch_timeout=10,
|
||||
fetch_retries=3, max_size=None, cache_ttl=0, snippet_len=0,
|
||||
categories=None, language=None, pageno=1, time_range="year",
|
||||
safesearch=0, engines="google,bing",
|
||||
)
|
||||
with patch.object(search_mod, "search_multi", side_effect=err):
|
||||
_, _, err_code = _run_single_query(
|
||||
"test", args, ["https://x.example.com"], {}, 0)
|
||||
assert err_code == E_RATE_LIMIT
|
||||
Reference in New Issue
Block a user