"""工具适配层测试: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