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)
This commit is contained in:
2026-08-25 02:40:43 +08:00
parent e0db5ab2cf
commit 1b66a70859
25 changed files with 3807 additions and 4 deletions
+85
View File
@@ -0,0 +1,85 @@
"""P2 验收链路:mock 宿主上"导入 → 铺轨 → 切/重建 → 删除 → 加效果 → 取帧验证 →
整段会话撤销"全链路自动化通过(计划文档 §4 P2 验收)。
关键断言:帧非全黑、编辑前后切口帧像素不同、undo_session 后时间线恢复原状。
"""
import hashlib
from mock_host import MockHostAdapter, decode_png, frame_is_black
from quercus_core.tools.executor import HostToolExecutor
from quercus_core.types import Action, Rational
FRAME_SIZE = {"width": 64, "height": 36}
def r(x: int) -> dict:
return {"num": x, "den": 1}
def _frame_at(executor, t_json, size=FRAME_SIZE):
result = executor.execute(Action(tool="get_frame", params={"time": t_json, "max_size": size}))
assert result.ok, f"get_frame 失败: {result.summary}"
assert len(result.images) == 1
return result.images[0]
def _sha(png: bytes) -> str:
return hashlib.sha256(png).hexdigest()
def _first_clips(adapter):
seq_id = adapter.get_project_overview().timeline_ids[0]
return adapter.get_timeline_structure(seq_id).tracks[0].clips
def test_p2_acceptance_chain():
adapter = MockHostAdapter()
executor = HostToolExecutor(adapter)
def act(tool, **params):
result = executor.execute(Action(tool=tool, params=params))
assert result.ok, f"{tool} 失败: {result.summary}"
return result
# 1) 导入两个素材
act("import_footage", paths=["/media/alpha.mp4", "/media/beta.mp4"])
footage = {it["name"]: it["id"] for it in adapter.list_footage()}
f_alpha = footage["alpha.mp4"]
f_beta = footage["beta.mp4"]
# 2) 铺轨:alpha[0,4) + beta[4,8)
act("place_clip", clip_id=f_alpha, track_index=1, time=r(0), in_point=r(0), out_point=r(4))
act("place_clip", clip_id=f_beta, track_index=1, time=r(4), in_point=r(0), out_point=r(4))
clip_a = _first_clips(adapter)[0]
# 3) 切口帧(编辑前):t=3 处是 alpha 的画面
frame_before = _frame_at(executor, r(3))
assert not frame_is_black(frame_before, 64, 36), "编辑前切口帧不得全黑"
decode_png(frame_before) # 必须是合法 PNG
# 4) 在 t=2 处切开 alpha
act("split_clip", clip_id=clip_a.id, time=r(2))
right_half = next(c for c in _first_clips(adapter) if c.start == Rational(2))
# 5) 波纹删除右半段 → beta 前移到 [2,6)
act("ripple_delete", clip_ids=[right_half.id])
frame_after_cut = _frame_at(executor, r(3))
assert not frame_is_black(frame_after_cut, 64, 36)
# 编辑前后切口帧像素不同(beta 代替了 alpha 出现在 t=3)
assert _sha(frame_after_cut) != _sha(frame_before), "切口帧像素应随编辑变化"
# 6) 给 beta 加效果并设参数 → 画面再变
beta_clip = next(c for c in _first_clips(adapter) if c.source == f_beta)
act("add_effect", clip_id=beta_clip.id, effect_type="brightness")
node_id = adapter._must_clip(beta_clip.id).effects[0].node_id # 白盒取节点
act("set_param", effect_id=node_id, param="level", value=0.5)
frame_after_effect = _frame_at(executor, r(3))
assert not frame_is_black(frame_after_effect, 64, 36)
assert _sha(frame_after_effect) != _sha(frame_after_cut), "加效果后画面应改变"
# 7) 整段会话撤销 → 时间线恢复原状(回到首个变更批次之前:空)
restored = executor.undo_session()
assert restored >= 6, f"应撤销 ≥6 个批次,实际 {restored}"
overview = adapter.get_project_overview()
assert overview.timeline_ids == [] # 无时间线 = 铺轨前的原状
assert adapter.list_footage() == [] # 连导入也被撤销(快照覆盖首个批次)
+363
View File
@@ -0,0 +1,363 @@
"""工具适配层测试: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
+112
View File
@@ -0,0 +1,112 @@
"""取帧流水线测试:mock 宿主限流下,批量扫描经退避最终成功且不超限流。
- 前置令牌桶按 ``Limits`` 限速 → 扫描耗时受速率约束(不超发);
- 宿主 ``RateLimited`` → 执行器按 retry_after_ms 有限退避重试(不轰炸);
- 批量取帧并发执行(串行等帧被禁止)。
"""
import time
import pytest
from mock_host import MockHostAdapter, frame_is_black
from quercus_core.host.errors import RateLimited
from quercus_core.host.types import Target
from quercus_core.tools.executor import HostToolExecutor
from quercus_core.tools.ratelimit import TokenBucket
from quercus_core.types import Action, Limits, Rational, Size
def r(x: int) -> dict:
return {"num": x, "den": 1}
def setup_clip(adapter: MockHostAdapter) -> None:
"""铺一条 alpha[0,4) 的基础时间线(供扫描取帧)。"""
fid = adapter.import_footage(["/media/alpha.mp4"])[0]
adapter._ensure_sequence()
from quercus_core.types import ActionBatch
adapter.execute(
ActionBatch(
label="setup",
actions=[
Action(
tool="place_clip",
params={"clip_id": fid, "track_index": 1, "time": r(0), "in_point": r(0), "out_point": r(4)},
)
],
)
)
def test_scan_concurrent_under_rate_limit():
"""默认 8 帧/s burst 4:8 帧扫描应在 ~0.5s 内完成且全部返回。"""
adapter = MockHostAdapter() # 默认 limits:8 帧/s,burst 4
executor = HostToolExecutor(adapter, frame_concurrency=4)
setup_clip(adapter)
t0 = time.monotonic()
result = executor.execute(
Action(tool="scan_timeline", params={"range": {"start": r(0), "end": r(4)}, "count": 8})
)
elapsed = time.monotonic() - t0
assert result.ok, result.summary
assert len(result.images) == 8
# 前置令牌桶按 8/s 限速:burst 4 之外每帧至少 0.125s → 总耗时 ≥ ~0.5s
assert elapsed >= 0.4, f"批量扫描不应超发令牌(耗时 {elapsed:.2f}s)"
assert elapsed <= 5.0
for img in result.images:
assert not frame_is_black(img, 480, 270)
def test_scan_backoff_on_rate_limited():
"""宿主限流严格(4 帧/s burst 2)、执行器前置桶宽松 → 必须走 RateLimited 退避。"""
adapter = MockHostAdapter(limits=Limits(max_frame_rate=4.0, frame_burst=2))
executor = HostToolExecutor(
adapter,
frame_concurrency=4,
rate_limiter=TokenBucket(capacity=64, refill_rate=1e6), # 前置桶放得很宽
)
setup_clip(adapter)
t0 = time.monotonic()
result = executor.execute(
Action(tool="scan_timeline", params={"range": {"start": r(0), "end": r(4)}, "count": 6})
)
elapsed = time.monotonic() - t0
assert result.ok, result.summary
assert len(result.images) == 6
assert adapter._rate_limited_count > 0, "应真实触发宿主限流"
# (6-2)/4 = 1.0s 的令牌补充时间,退避后成功
assert elapsed >= 0.8, f"退避耗时不足({elapsed:.2f}s)"
assert elapsed <= 6.0
def test_fetch_retries_exhausted_reports_error():
"""令牌彻底耗尽且超过重试上限时,错误回喂而非无限轰炸。"""
adapter = MockHostAdapter(limits=Limits(max_frame_rate=1.0, frame_burst=1))
executor = HostToolExecutor(
adapter,
frame_concurrency=1,
max_frame_retries=0, # 不允许重试
rate_limiter=TokenBucket(capacity=64, refill_rate=1e6),
)
setup_clip(adapter)
result = executor.execute(
Action(tool="scan_timeline", params={"range": {"start": r(0), "end": r(4)}, "count": 3})
)
assert result.ok is False
assert result.images # 至少第一帧成功
assert "限流" in (result.error or "") or "失败" in (result.summary or "")
def test_rate_limited_raised_directly():
"""get_frame 遇限流直接抛 RateLimited(退避逻辑由流水线负责)。"""
adapter = MockHostAdapter(limits=Limits(max_frame_rate=1.0, frame_burst=1))
setup_clip(adapter)
seq = adapter.get_project_overview().timeline_ids[0]
adapter.get_frame(Target(kind="timeline", id=seq), Rational(1), Size(32, 18))
with pytest.raises(RateLimited):
adapter.get_frame(Target(kind="timeline", id=seq), Rational(2), Size(32, 18))
+267
View File
@@ -0,0 +1,267 @@
"""MockHostAdapter 行为测试:id 失效、限流、PNG 合法非全黑随时间变化、快照/恢复。
mock 宿主是未来 Oak/Resolve/Premiere 适配层的验收替身(计划文档 §3.3 / §7),
这些断言即未来真实适配层要达标的行为契约。
"""
import hashlib
import pytest
from mock_host import MockHostAdapter, decode_png, frame_is_black
from quercus_core.host.errors import EntityNotFound, RateLimited
from quercus_core.host.types import Target
from quercus_core.types import (
Action,
ActionBatch,
Capabilities,
Limits,
Rational,
Size,
TimeRange,
)
def r(x: int) -> dict:
"""有理秒参数 {num, den}。"""
return {"num": x, "den": 1}
def setup_base(adapter: MockHostAdapter) -> None:
"""铺一条基础时间线:alpha[0,4) + beta[4,8) 在轨道 1。"""
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),
},
),
],
)
)
def _first_seq(adapter: MockHostAdapter):
return adapter.get_project_overview().timeline_ids[0]
def _timeline_clips(adapter: MockHostAdapter):
return adapter.get_timeline_structure(_first_seq(adapter)).tracks[0].clips
def _sha(png: bytes) -> str:
return hashlib.sha256(png).hexdigest()
# ---- 能力与限制 ----
def test_capabilities_and_limits():
a = MockHostAdapter()
assert Capabilities.TIMELINE_EDIT in a.capabilities()
assert a.limits().max_frame_rate == 8.0
assert a.limits().frame_burst == 4
def test_entity_ids_are_opaque_strings():
a = MockHostAdapter()
ids = a.import_footage(["/media/x.mp4"])
assert isinstance(ids[0], str)
assert ids[0].startswith("ft_")
# ---- id 失效(删除后 EntityNotFound) ----
def test_clip_id_invalid_after_delete():
a = MockHostAdapter()
setup_base(a)
clip_id = _timeline_clips(a)[0].id
a.execute(ActionBatch(label="del", actions=[Action(tool="ripple_delete", params={"clip_ids": [clip_id]})]))
with pytest.raises(EntityNotFound):
a.get_frame(Target(kind="clip", id=clip_id), Rational(1), Size(32, 18))
def test_unknown_footage_raises_entity_not_found():
a = MockHostAdapter()
with pytest.raises(EntityNotFound):
a.get_timeline_structure("no_such_seq")
def test_unknown_clip_place_fails():
a = MockHostAdapter()
br = a.execute(
ActionBatch(
label="t",
actions=[Action(tool="place_clip", params={"clip_id": "nope", "track_index": 1, "time": r(0)})],
)
)
assert br.ok is False
assert "不存在" in br.results[0].summary
# ---- 限流模拟 ----
def test_rate_limited_raised_with_retry_after():
a = MockHostAdapter(limits=Limits(max_frame_rate=1.0, frame_burst=1))
setup_base(a)
seq = _first_seq(a)
a.get_frame(Target(kind="timeline", id=seq), Rational(1), Size(32, 18)) # 耗掉唯一令牌
with pytest.raises(RateLimited) as ei:
a.get_frame(Target(kind="timeline", id=seq), Rational(2), Size(32, 18))
assert ei.value.retry_after_ms > 0
assert a._rate_limited_count >= 1
# ---- 帧:合法 PNG、非全黑、随时间变化 ----
def test_frame_png_valid_nonblack():
a = MockHostAdapter()
setup_base(a)
seq = _first_seq(a)
png = a.get_frame(Target(kind="timeline", id=seq), Rational(1), Size(64, 36))
assert png.startswith(b"\x89PNG\r\n\x1a\n")
rgb = decode_png(png) # 解不开即抛 ValueError(非法 PNG)
assert len(rgb) == 64 * 36 * 3
assert not frame_is_black(png, 64, 36)
def test_frame_pixels_vary_with_time():
a = MockHostAdapter()
setup_base(a)
seq = _first_seq(a)
p1 = a.get_frame(Target(kind="timeline", id=seq), Rational(1), Size(64, 36))
p2 = a.get_frame(Target(kind="timeline", id=seq), Rational(2), Size(64, 36))
assert _sha(p1) != _sha(p2)
assert decode_png(p1) != decode_png(p2)
def test_thumbnails_valid_and_distinct():
a = MockHostAdapter(limits=Limits(max_frame_rate=1000.0, frame_burst=100))
setup_base(a)
seq = _first_seq(a)
thumbs = a.get_thumbnails(Target(kind="timeline", id=seq), TimeRange(Rational(0), Rational(4)), 5)
assert len(thumbs) == 5
hashes = {_sha(t) for t in thumbs}
assert len(hashes) == 5 # 各采样点画面不同
for t in thumbs:
assert not frame_is_black(t, 480, 270)
# ---- 快照 / 恢复 / 清理 ----
def test_snapshot_restore_and_drop():
a = MockHostAdapter()
setup_base(a) # 直接 import_footage + execute(setup 批次) → 快照 "setup"
assert len(_timeline_clips(a)) == 2
a.execute(
ActionBatch(label="mark", session_id="s1", actions=[Action(tool="add_marker", params={"time": r(2)})])
)
snaps = a.list_snapshots()
assert {s.label for s in snaps} == {"setup", "mark"}
mark_snap = next(s for s in snaps if s.label == "mark")
assert mark_snap.session_id == "s1" # 快照带会话 id
assert mark_snap.created_at # 快照带时间戳
# 恢复 setup 快照 → 回到铺轨前:无序列无片段,但直接导入的素材仍在
setup_snap = next(s for s in a.list_snapshots() if s.label == "setup")
a.restore_snapshot(setup_snap.id)
assert a.get_project_overview().timeline_ids == []
assert len(a.list_footage()) == 2
# 清理快照后不可再恢复
a.drop_snapshot(mark_snap.id)
with pytest.raises(EntityNotFound):
a.restore_snapshot(mark_snap.id)
def test_snapshot_cap_eviction():
a = MockHostAdapter(limits=Limits(max_snapshot_count=2))
for i in range(3):
a.execute(
ActionBatch(
label=f"b{i}",
actions=[Action(tool="add_marker", params={"time": r(i + 1)})],
)
)
snaps = a.list_snapshots()
assert len(snaps) == 2 # 最旧的被淘汰
assert {s.label for s in snaps} == {"b1", "b2"}
def test_undo_last_restores_newest_snapshot():
a = MockHostAdapter()
setup_base(a)
before = len(_timeline_clips(a))
a.execute(ActionBatch(label="m1", actions=[Action(tool="add_marker", params={"time": r(1)})]))
a.execute(ActionBatch(label="m2", actions=[Action(tool="add_marker", params={"time": r(2)})]))
a.undo_last() # 撤销 m2
a.undo_last() # 撤销 m1
# 时间线片段数回到 setup 后的状态
assert len(_timeline_clips(a)) == before
# ---- 事件订阅 ----
def test_event_subscription():
a = MockHostAdapter()
seen: list[str] = []
unsub = a.subscribe(lambda ev: seen.append(ev.kind))
setup_base(a)
assert "structure_changed" in seen
unsub()
seen.clear()
setup_base(a) # 不再通知
assert seen == []
# ---- 音频电平 / 效果参数 ----
def test_audio_levels_shape():
a = MockHostAdapter()
setup_base(a)
levels = a.get_audio_levels(_first_seq(a), TimeRange(Rational(0), Rational(4)), 16)
assert len(levels.values) == 16
assert min(levels.values) >= levels.min_db
def test_effect_params_min_max_choices():
a = MockHostAdapter()
params = a.get_effect_params("brightness")
level = next(p for p in params if p["name"] == "level")
assert level["min"] == -1.0 and level["max"] == 1.0
blend = next(p for p in a.get_effect_params("overlay") if p["name"] == "blend_mode")
assert blend["choices"] == ["normal", "screen", "multiply"]
with pytest.raises(EntityNotFound):
a.get_effect_params("no_such_effect")
def test_set_param_rejects_out_of_range():
a = MockHostAdapter()
setup_base(a)
clip_id = _timeline_clips(a)[0].id
a.execute(
ActionBatch(
label="fx",
actions=[Action(tool="add_effect", params={"clip_id": clip_id, "effect_type": "brightness"})],
)
)
node_id = a._must_clip(clip_id).effects[0].node_id # 白盒取节点 id
bad = a.execute(
ActionBatch(label="bad", actions=[Action(tool="set_param", params={"effect_id": node_id, "param": "level", "value": 5.0})])
)
assert bad.ok is False # 5.0 超出 level 的 [-1,1]
assert "非法" in bad.results[0].summary or "失败" in bad.results[0].summary
+217
View File
@@ -0,0 +1,217 @@
"""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()