Files
projectAIpopular/tests/test_architect.py
T

170 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""T3 ArchitectClient 单测(封闭:httpx.MockTransport 注入,D11)。"""
import json
import httpx
import pytest
from router_system.architect import (
ArchitectCircuitBreaker,
ArchitectClient,
ArchitectError,
build_architect,
)
from router_system.workspace import Workspace
BRIEF_JSON = json.dumps({
"goal": "实现快排",
"constraints": ["标准库"],
"tags": ["code"],
"acceptance": [{"id": "a1", "check": "排序正确", "machine_checkable": True}],
"plan": [{"id": "s1", "task": "实现", "deps": [], "done_criteria": "可运行"}],
}, ensure_ascii=False)
DECIDE_JSON = json.dumps({"reply": "改用断言", "patch_plan": [{"id": "s2", "task": "修"}]}, ensure_ascii=False)
REVIEW_JSON = json.dumps({"verdict": "done", "notes": "通过", "fix_issues": []}, ensure_ascii=False)
def _make_client(handler, api_key="test-key", **kw):
transport = httpx.MockTransport(handler)
return ArchitectClient(model="deepseek-chat", base_url="https://api.deepseek.com/v1",
api_key=api_key, transport=transport, **kw)
def _resp_json(content, usage=None):
return httpx.Response(200, json={
"choices": [{"message": {"content": content}}],
"usage": usage or {"prompt_tokens": 100, "completion_tokens": 20},
})
def _ws(**kw):
return Workspace.new(request_id="a1b2c3d4e5f6", query=kw.get("query", "写个快排"),
api_token_cap=kw.get("cap", 8000), rounds_cap=kw.get("rounds", 6))
# ---------- brief 成功 ----------
def test_brief_success_records_budget():
calls = []
def handler(request):
calls.append(request.url.path)
return _resp_json(BRIEF_JSON)
client = _make_client(handler)
ws = _ws()
brief = asyncio_run(client.brief("写个快排", ws))
assert brief["goal"] == "实现快排"
assert brief["plan"][0]["id"] == "s1"
assert calls == ["/v1/chat/completions"]
# token 计量回写
assert ws.budget()["api_input_tokens"] == 100
assert ws.budget()["api_output_tokens"] == 20
# ---------- 缺 key ----------
def test_no_key_raises():
client = ArchitectClient(model="deepseek-chat", api_key=None)
ws = _ws()
with pytest.raises(ArchitectError):
asyncio_run(client.brief("hi", ws))
# ---------- 坏 JSON 重试一次成功 ----------
def test_bad_json_retry_once_success():
seq = [{"body": "不是json{{", "ok": False}, {"body": BRIEF_JSON, "ok": True}]
calls = []
def handler(request):
calls.append(1)
item = seq[len(calls) - 1]
return _resp_json(item["body"])
client = _make_client(handler)
ws = _ws()
brief = asyncio_run(client.brief("hi", ws))
assert len(calls) == 2
assert brief["goal"] == "实现快排"
# ---------- 坏 JSON 两次失败 ----------
def test_bad_json_twice_raises():
def handler(request):
return _resp_json("垃圾输出{")
client = _make_client(handler)
ws = _ws()
with pytest.raises(ArchitectError):
asyncio_run(client.brief("hi", ws))
# ---------- API 错误(非 2xx ----------
def test_http_error_raises():
def handler(request):
return httpx.Response(500, text="server error")
client = _make_client(handler)
ws = _ws()
with pytest.raises(ArchitectError):
asyncio_run(client.brief("hi", ws))
# ---------- 缺 choices ----------
def test_missing_choices_raises():
def handler(request):
return httpx.Response(200, json={"usage": {}})
client = _make_client(handler)
ws = _ws()
with pytest.raises(ArchitectError):
asyncio_run(client.brief("hi", ws))
# ---------- 熔断:预算触顶,不再调用 transport ----------
def test_circuit_breaker_before_transport():
called = []
def handler(request):
called.append(1)
return _resp_json(BRIEF_JSON)
client = _make_client(handler)
ws = _ws(cap=1)
ws.add_budget(input_tokens=1, output_tokens=0) # used=1 >= cap=1
with pytest.raises(ArchitectCircuitBreaker):
asyncio_run(client.brief("hi", ws))
assert called == [] # 未触达 API
# ---------- decide / final_review ----------
def test_decide():
def handler(request):
return _resp_json(DECIDE_JSON)
client = _make_client(handler)
ws = _ws()
ws.apply_brief({"goal": "x", "constraints": [], "tags": ["code"],
"acceptance": [], "plan": [{"id": "s1", "task": "t", "deps": [], "done_criteria": "c"}]})
ws.add_issue("s1", "a://f.py#L1", "obs", "exp", "try", "ask")
out = asyncio_run(client.decide(ws))
assert out["reply"] == "改用断言"
def test_final_review_done():
def handler(request):
return _resp_json(REVIEW_JSON)
client = _make_client(handler)
ws = _ws()
ws.apply_brief({"goal": "x", "constraints": [], "tags": ["code"],
"acceptance": [], "plan": [{"id": "s1", "task": "t", "deps": [], "done_criteria": "c"}]})
out = asyncio_run(client.final_review(ws))
assert out["verdict"] == "done"
# ---------- build_architect 工厂 ----------
def test_build_architect_reads_env(monkeypatch):
cfg = {"model": "deepseek-chat", "api_key_env": "DEEPSEEK_API_KEY"}
client = build_architect(cfg, get_env=lambda name: "sk-fake")
assert client.api_key == "sk-fake"
def test_build_architect_no_key():
cfg = {"api_key_env": "DEEPSEEK_API_KEY"}
client = build_architect(cfg, get_env=lambda name: None)
assert client.api_key is None
# ---------- 小工具 ----------
def asyncio_run(coro):
import asyncio
return asyncio.run(coro)