- 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)
218 lines
7.6 KiB
Python
218 lines
7.6 KiB
Python
"""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()
|