Files
Mike-Solar 1b66a70859 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)
2026-08-25 02:40:43 +08:00

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