- 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)
364 lines
12 KiB
Python
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
|