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:
2026-08-25 02:40:43 +08:00
parent e0db5ab2cf
commit 1b66a70859
25 changed files with 3807 additions and 4 deletions
+217
View File
@@ -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()