- 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)
268 lines
8.7 KiB
Python
268 lines
8.7 KiB
Python
"""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
|