"""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)