Files
quercus/tests/core/test_executor.py
T
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

364 lines
12 KiB
Python

"""工具适配层测试:26 个工具 handler 在 mock 宿主上逐个过一遍 + get_params 回填。
每个工具至少一个用例;变更类工具经 executor 走 adapter.execute 快照批次。
"""
import re
import pytest
from mock_host import MockHostAdapter, decode_png
from quercus_core.tools.executor import HostToolExecutor
from quercus_core.tools.schemas import TOOL_LIST, TOOLS
from quercus_core.types import Action, ActionBatch, Rational
def r(x: int) -> dict:
return {"num": x, "den": 1}
@pytest.fixture
def fresh():
"""预铺好的 mock 宿主 + 执行器(alpha[0,4) + beta[4,8) 在轨道 1)。"""
adapter = MockHostAdapter()
executor = HostToolExecutor(adapter)
f_alpha, f_beta = adapter.import_footage(["/media/alpha.mp4", "/media/beta.mp4"])
adapter.execute(
ActionBatch(
label="setup",
actions=[
Action(
tool="place_clip",
params={"clip_id": f_alpha, "track_index": 1, "time": r(0), "in_point": r(0), "out_point": r(4)},
),
Action(
tool="place_clip",
params={"clip_id": f_beta, "track_index": 1, "time": r(4), "in_point": r(0), "out_point": r(4)},
),
],
)
)
return adapter, executor
def _seq(adapter):
return adapter.get_project_overview().timeline_ids[0]
def _clips(adapter):
return adapter.get_timeline_structure(_seq(adapter)).tracks[0].clips
def _exec(executor, tool, **params):
return executor.execute(Action(tool=tool, params=params))
def test_all_26_tools_registered():
assert len(TOOLS) == 26
assert len(TOOL_LIST) == 26
# ---- 工程 ----
def test_open_project(fresh):
adapter, executor = fresh
result = _exec(executor, "open_project", path="/tmp/demo.qproj")
assert result.ok
assert "已打开" in result.summary
assert adapter.get_project_overview().name == "demo"
def test_save_project(fresh):
adapter, executor = fresh
result = _exec(executor, "save_project", path="/tmp/demo.qproj")
assert result.ok
assert adapter._state.project.path == "/tmp/demo.qproj"
assert adapter._state.project.modified is False
def test_get_project_overview(fresh):
adapter, executor = fresh
result = _exec(executor, "get_project_overview")
assert result.ok
assert "工程:" in result.summary
assert "时间线" in result.summary
# ---- 媒体 ----
def test_probe_media(fresh):
adapter, executor = fresh
result = _exec(executor, "probe_media", path="/media/alpha.mp4")
assert result.ok
assert "分辨率" in result.summary
def test_import_footage(fresh):
adapter, executor = fresh
result = _exec(executor, "import_footage", paths=["/media/gamma.mp4"])
assert result.ok
assert any("gamma" in it["path"] for it in adapter.list_footage())
def test_list_footage(fresh):
adapter, executor = fresh
result = _exec(executor, "list_footage")
assert result.ok
assert "2" in result.summary
# ---- 时间线 ----
def test_add_track(fresh):
adapter, executor = fresh
result = _exec(executor, "add_track", type="audio")
assert result.ok
tl = adapter.get_timeline_structure(_seq(adapter))
assert any(t.kind == "audio" for t in tl.tracks)
def test_place_clip(fresh):
adapter, executor = fresh
fid = adapter.list_footage()[0]["id"]
result = _exec(executor, "place_clip", clip_id=fid, track_index=2, time=r(0), in_point=r(0), out_point=r(2))
assert result.ok
assert "已放置" in result.summary
tl = adapter.get_timeline_structure(_seq(adapter))
placed = [c for t in tl.tracks if t.index == 2 for c in t.clips]
assert len(placed) == 1 and placed[0].duration == Rational(2)
def test_split_clip(fresh):
adapter, executor = fresh
clip_id = _clips(adapter)[0].id
result = _exec(executor, "split_clip", clip_id=clip_id, time=r(2))
assert result.ok
assert len(_clips(adapter)) == 3 # 左右两段 + beta
def test_trim_clip(fresh):
adapter, executor = fresh
clip_id = _clips(adapter)[0].id
result = _exec(executor, "trim_clip", clip_id=clip_id, in_point=r(1), duration=r(2))
assert result.ok
clip = next(c for c in _clips(adapter) if c.id == clip_id)
assert clip.duration == Rational(2)
assert clip.in_point == Rational(1)
def test_move_clip(fresh):
adapter, executor = fresh
clip_id = _clips(adapter)[0].id
result = _exec(executor, "move_clip", clip_id=clip_id, track_index=2, time=r(1))
assert result.ok
tl = adapter.get_timeline_structure(_seq(adapter))
moved = [c for t in tl.tracks if t.index == 2 for c in t.clips]
assert len(moved) == 1 and moved[0].start == Rational(1)
def test_ripple_delete(fresh):
adapter, executor = fresh
clip_a = _clips(adapter)[0].id
result = _exec(executor, "ripple_delete", clip_ids=[clip_a])
assert result.ok
clips = _clips(adapter)
assert len(clips) == 1
assert clips[0].start == Rational(0) # beta 前移到开头(空隙闭合)
def test_add_transition(fresh):
adapter, executor = fresh
clip_id = _clips(adapter)[0].id
result = _exec(executor, "add_transition", clip_id=clip_id, transition_type="cross_dissolve", duration=r(1))
assert result.ok
assert "cross_dissolve" in result.summary
def test_add_marker(fresh):
adapter, executor = fresh
result = _exec(executor, "add_marker", time=r(1), label="打点", color="red", custom_data={"ai": True})
assert result.ok
seq = adapter._must_seq(_seq(adapter))
assert len(seq.markers) == 1 and seq.markers[0].label == "打点"
# ---- 效果 / 关键帧 ----
def test_add_effect(fresh):
adapter, executor = fresh
clip_id = _clips(adapter)[0].id
result = _exec(executor, "add_effect", clip_id=clip_id, effect_type="brightness")
assert result.ok
assert "brightness" in result.summary
assert len(adapter._must_clip(clip_id).effects) == 1
def test_set_param(fresh):
adapter, executor = fresh
clip_id = _clips(adapter)[0].id
_exec(executor, "add_effect", clip_id=clip_id, effect_type="brightness")
node = adapter._must_clip(clip_id).effects[0].node_id
result = _exec(executor, "set_param", effect_id=node, param="level", value=0.5)
assert result.ok
assert adapter._must_effect(node).params["level"] == 0.5
def test_set_keyframe(fresh):
adapter, executor = fresh
clip_id = _clips(adapter)[0].id
_exec(executor, "add_effect", clip_id=clip_id, effect_type="brightness")
node = adapter._must_clip(clip_id).effects[0].node_id
result = _exec(executor, "set_keyframe", effect_id=node, param="level", time=r(1), value=0.8)
assert result.ok
assert adapter._must_effect(node).keyframes["level"][0].value == 0.8
def test_list_effects_available_and_on_clip(fresh):
adapter, executor = fresh
result = _exec(executor, "list_effects")
assert result.ok
assert "brightness" in result.summary
clip_id = _clips(adapter)[0].id
_exec(executor, "add_effect", clip_id=clip_id, effect_type="crop")
result2 = _exec(executor, "list_effects", clip_id=clip_id)
assert result2.ok
assert "crop" in result2.summary
# ---- 取帧 ----
def test_get_frame(fresh):
adapter, executor = fresh
result = _exec(executor, "get_frame", time=r(1), max_size={"width": 64, "height": 36})
assert result.ok
assert len(result.images) == 1
assert result.images[0].startswith(b"\x89PNG\r\n\x1a\n")
decode_png(result.images[0]) # 合法 PNG
def test_scan_timeline(fresh):
adapter, executor = fresh
result = _exec(executor, "scan_timeline", range={"start": r(0), "end": r(4)}, count=4)
assert result.ok
assert len(result.images) == 4
for img in result.images:
decode_png(img)
def test_get_audio_levels(fresh):
adapter, executor = fresh
result = _exec(executor, "get_audio_levels", range={"start": r(0), "end": r(4)}, resolution=16)
assert result.ok
assert "电平" in result.summary
# ---- 回放 ----
def test_play_pause_seek(fresh):
adapter, executor = fresh
assert _exec(executor, "play").ok
assert _exec(executor, "seek", time=r(2)).ok
state = adapter.get_state()
assert state.playing is True and state.position == Rational(2)
assert _exec(executor, "pause").ok
assert adapter.get_state().playing is False
# ---- 导出 / 撤销 ----
def test_export_render(fresh):
adapter, executor = fresh
result = _exec(executor, "export_render", output="/tmp/out.mp4", preset="h264")
assert result.ok
assert "导出" in result.summary
def test_undo_last_action(fresh):
adapter, executor = fresh
before = len(_clips(adapter))
_exec(executor, "add_marker", time=r(1))
result = _exec(executor, "undo_last_action")
assert result.ok
assert len(_clips(adapter)) == before # 快照恢复到 add_marker 前
assert "撤销" in result.summary
# ---- get_params 回填(min/max/choices 供未来 schema 回填) ----
def test_get_params_backfill(fresh):
adapter, executor = fresh
brightness = executor.get_params("brightness")
level = next(p for p in brightness if p["name"] == "level")
assert level["min"] == -1.0 and level["max"] == 1.0
blend = next(p for p in executor.get_params("overlay") if p["name"] == "blend_mode")
assert blend["choices"] == ["normal", "screen", "multiply"]
assert executor.get_params("no_such") == [] # 未知效果类型优雅降级
def test_set_param_rejects_invalid_choice(fresh):
adapter, executor = fresh
clip_id = _clips(adapter)[0].id
_exec(executor, "add_effect", clip_id=clip_id, effect_type="overlay")
node = adapter._must_clip(clip_id).effects[0].node_id
result = _exec(executor, "set_param", effect_id=node, param="blend_mode", value="blur")
assert result.ok is False
assert "非法" in result.summary or "失败" in result.summary
# ---- 批级执行(一个批次一个快照) ----
def test_execute_batch_single_snapshot(fresh):
adapter, executor = fresh
before = len(executor.list_snapshots())
batch = ActionBatch(
label="two-markers",
session_id=executor.session_id,
actions=[
Action(tool="add_marker", params={"time": r(1), "label": "m1"}),
Action(tool="add_marker", params={"time": r(2), "label": "m2"}),
],
)
results = executor.execute_batch(batch)
assert all(res.ok for res in results)
assert len(executor.list_snapshots()) == before + 1 # 一批次一个快照
# ---- AgentLoop 集成:mock LLM 派发到 executor ----
def test_agent_loop_with_host_executor(fresh):
from mock_llm import MockProvider
from quercus_core.agent.loop import AgentLoop
from quercus_core.providers.base import AssistantTurn, ToolCall
adapter, executor = fresh
fid = adapter.list_footage()[0]["id"]
mock = MockProvider(
script=[
AssistantTurn(
text="",
tool_calls=(
ToolCall(
id="c1",
name="add_marker",
arguments={"time": {"num": 3, "den": 1}, "label": "AI 打点"},
),
ToolCall(
id="c2",
name="get_frame",
arguments={"time": {"num": 1, "den": 1}, "max_size": {"width": 64, "height": 36}},
),
),
),
AssistantTurn(text="完成", tool_calls=()),
]
)
loop = AgentLoop(provider=mock, executor=executor, confirm=lambda batch: True)
result = loop.run("在 3 秒处打点并看一眼画面")
assert result.turns == 2
seq = adapter._must_seq(_seq(adapter))
assert any(m.label == "AI 打点" for m in seq.markers) # 变更已生效
# get_frame 的 PNG 回喂给 provider
last = mock.observed[-1]
tool_msgs = [m for m in last if m.role == "tool"]
frame_msg = next(m for m in tool_msgs if m.name == "get_frame")
assert len(frame_msg.images) == 1