P2: tool adapter layer, mock host, frame pipeline, and local web UI
- MockHostAdapter (tests/mock_host): in-memory editing model with opaque entity ids, id invalidation, token-bucket rate limiting, snapshot/restore with cap, event subscription, and a stdlib-zlib PNG encoder producing time-varying non-black frames - HostToolExecutor: all 26 tool handlers mapped to HostAdapter calls, snapshot orchestration for mutating batches, concurrent scan_timeline frame pipeline with token bucket and RateLimited backoff, undo_session() restoring per-batch snapshots in reverse, get_params min/max/choices reflection - Web UI (core[webui] extra): FastAPI + single-page vanilla JS, chat log, pending-confirmation list with approve/reject endpoints (timeout defaults to reject), progress, undo-session, snapshot management; SSE push + POST, bound to 127.0.0.1 - Host error types (HostError/EntityNotFound/RateLimited) and shared TokenBucket - P2 acceptance chain: import -> place -> split -> ripple delete -> effect + param -> frame verification (non-black, pixel changes at cut and after effect) -> undo_session restores original state Tests: 129 passed via uv run pytest (no network)
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
"""Web 面板测试:HTTP/SSE 端点的集成路径(confirm approve/reject 经端点走通)。
|
||||
|
||||
- confirm approve / reject 流程经 HTTP 端点裁决;
|
||||
- undo_session / 快照管理端点;
|
||||
- SSE 事件流(用真实 uvicorn 起服务走 TCP,TestClient 会缓冲无限流);
|
||||
- EventBroker 单元测试。
|
||||
本模块依赖可选 extra ``core[webui]``,未安装时整模块 skip。
|
||||
"""
|
||||
import http.client
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
fastapi = pytest.importorskip("fastapi")
|
||||
import uvicorn # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from mock_host import MockHostAdapter # noqa: E402
|
||||
from mock_llm import MockProvider # noqa: E402
|
||||
from quercus_core.providers.base import AssistantTurn, ToolCall # noqa: E402
|
||||
from quercus_core.types import Action # noqa: E402
|
||||
from quercus_core.webui.app import create_app # noqa: E402
|
||||
from quercus_core.webui.broker import EventBroker # noqa: E402
|
||||
|
||||
|
||||
def _wait_pending(client, timeout=5.0):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
data = client.get("/api/pending").json()
|
||||
if data["pending"]:
|
||||
return data["pending"][0]
|
||||
time.sleep(0.02)
|
||||
return None
|
||||
|
||||
|
||||
# ---- 基础端点 ----
|
||||
|
||||
def test_index_served():
|
||||
app = create_app(adapter=MockHostAdapter(), provider=MockProvider(script=[]))
|
||||
client = TestClient(app)
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert "<html" in r.text
|
||||
|
||||
|
||||
def test_status_endpoint():
|
||||
app = create_app(adapter=MockHostAdapter(), provider=MockProvider(script=[]))
|
||||
client = TestClient(app)
|
||||
data = client.get("/api/status").json()
|
||||
assert data["ready"] is True
|
||||
|
||||
|
||||
# ---- confirm approve / reject(必须的验收路径) ----
|
||||
|
||||
def test_confirm_approve_flow():
|
||||
adapter = MockHostAdapter()
|
||||
provider = MockProvider(
|
||||
script=[
|
||||
AssistantTurn(
|
||||
text="",
|
||||
tool_calls=(ToolCall(id="c1", name="import_footage", arguments={"paths": ["/media/a.mp4"]}),),
|
||||
),
|
||||
AssistantTurn(text="导入完成", tool_calls=()),
|
||||
]
|
||||
)
|
||||
app = create_app(adapter=adapter, provider=provider, confirm_timeout=10)
|
||||
client = TestClient(app)
|
||||
assert client.post("/api/chat", json={"text": "导入素材 a"}).json()["ok"] is True
|
||||
|
||||
batch = _wait_pending(client)
|
||||
assert batch is not None
|
||||
assert batch["actions"][0]["tool"] == "import_footage"
|
||||
|
||||
r = client.post(f"/api/confirm/{batch['batch_id']}", json={"approved": True})
|
||||
assert r.status_code == 200 and r.json()["ok"] is True
|
||||
assert app.state.session.wait_done(10)
|
||||
assert len(adapter.list_footage()) == 1 # 确认后变更已执行
|
||||
|
||||
|
||||
def test_confirm_reject_flow():
|
||||
adapter = MockHostAdapter()
|
||||
provider = MockProvider(
|
||||
script=[
|
||||
AssistantTurn(
|
||||
text="",
|
||||
tool_calls=(ToolCall(id="c1", name="import_footage", arguments={"paths": ["/media/a.mp4"]}),),
|
||||
),
|
||||
AssistantTurn(text="已取消", tool_calls=()),
|
||||
]
|
||||
)
|
||||
app = create_app(adapter=adapter, provider=provider, confirm_timeout=10)
|
||||
client = TestClient(app)
|
||||
client.post("/api/chat", json={"text": "导入素材"})
|
||||
batch = _wait_pending(client)
|
||||
assert batch is not None
|
||||
|
||||
r = client.post(f"/api/confirm/{batch['batch_id']}", json={"approved": False})
|
||||
assert r.json()["ok"] is True
|
||||
assert app.state.session.wait_done(10)
|
||||
assert adapter.list_footage() == [] # 拒绝后未执行
|
||||
|
||||
|
||||
def test_confirm_unknown_batch_rejected():
|
||||
app = create_app(adapter=MockHostAdapter(), provider=MockProvider(script=[]))
|
||||
client = TestClient(app)
|
||||
r = client.post("/api/confirm/no_such_batch", json={"approved": True})
|
||||
assert r.json()["ok"] is False
|
||||
|
||||
|
||||
def test_chat_busy_guard():
|
||||
"""上一轮对话(待确认阻塞中)未结束前,拒绝新的发消息请求。"""
|
||||
provider = MockProvider(
|
||||
script=[AssistantTurn(text="", tool_calls=(ToolCall(id="c1", name="import_footage", arguments={"paths": ["/media/a.mp4"]}),))]
|
||||
)
|
||||
app = create_app(adapter=MockHostAdapter(), provider=provider, confirm_timeout=30)
|
||||
client = TestClient(app)
|
||||
assert client.post("/api/chat", json={"text": "第一条"}).json()["ok"] is True
|
||||
assert _wait_pending(client) is not None # 会话被待确认阻塞
|
||||
r = client.post("/api/chat", json={"text": "第二条"})
|
||||
assert r.json()["ok"] is False
|
||||
assert "进行" in r.json()["reason"]
|
||||
|
||||
|
||||
# ---- undo_session / 快照管理端点 ----
|
||||
|
||||
def test_undo_session_endpoint():
|
||||
adapter = MockHostAdapter()
|
||||
app = create_app(adapter=adapter, provider=MockProvider(script=[]))
|
||||
client = TestClient(app)
|
||||
app.state.session.executor.execute(Action(tool="import_footage", params={"paths": ["/media/a.mp4"]}))
|
||||
r = client.post("/api/undo_session")
|
||||
data = r.json()
|
||||
assert data["ok"] is True and data["restored"] >= 1
|
||||
assert adapter.list_footage() == [] # 时间线/媒体恢复原状
|
||||
|
||||
|
||||
def test_snapshot_endpoints():
|
||||
adapter = MockHostAdapter()
|
||||
app = create_app(adapter=adapter, provider=MockProvider(script=[]))
|
||||
client = TestClient(app)
|
||||
app.state.session.executor.execute(Action(tool="import_footage", params={"paths": ["/media/a.mp4"]}))
|
||||
snaps = client.get("/api/snapshots").json()["snapshots"]
|
||||
assert len(snaps) >= 1
|
||||
sid = snaps[0]["id"]
|
||||
assert client.post(f"/api/snapshots/{sid}/drop").json()["ok"] is True
|
||||
assert client.get("/api/snapshots").json()["snapshots"] == []
|
||||
|
||||
|
||||
# ---- EventBroker 单元 ----
|
||||
|
||||
def test_event_broker():
|
||||
broker = EventBroker()
|
||||
q1 = broker.subscribe()
|
||||
q2 = broker.subscribe()
|
||||
broker.publish({"kind": "system", "text": "hi"})
|
||||
assert q1.get(timeout=1)["text"] == "hi"
|
||||
assert q2.get(timeout=1)["text"] == "hi"
|
||||
broker.unsubscribe(q1)
|
||||
assert broker.subscriber_count == 1
|
||||
|
||||
|
||||
# ---- SSE 事件流(真实 uvicorn 走 TCP) ----
|
||||
|
||||
@pytest.fixture
|
||||
def live_server():
|
||||
adapter = MockHostAdapter()
|
||||
app = create_app(adapter=adapter, provider=MockProvider(script=[]))
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
|
||||
server = uvicorn.Server(config)
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
pytest.fail("uvicorn 未在超时内就绪")
|
||||
yield {"app": app, "port": port}
|
||||
server.should_exit = True
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_sse_stream_delivers_events(live_server):
|
||||
port = live_server["port"]
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
try:
|
||||
conn.request("GET", "/api/events")
|
||||
resp = conn.getresponse()
|
||||
assert resp.status == 200
|
||||
assert resp.getheader("content-type", "").startswith("text/event-stream")
|
||||
first = resp.readline()
|
||||
assert b"connected" in first # 首条事件确认流已就绪
|
||||
|
||||
# 发布一条真实事件(undo_session 会发 system 消息)
|
||||
live_server["app"].state.session.undo_session()
|
||||
got = None
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
line = resp.readline()
|
||||
if not line:
|
||||
break
|
||||
if line.startswith(b"data: "):
|
||||
got = json.loads(line[6:])
|
||||
break
|
||||
assert got is not None
|
||||
assert got["kind"] == "system"
|
||||
finally:
|
||||
conn.close()
|
||||
Reference in New Issue
Block a user