Files
quercus/tests/core/test_mock_host.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

268 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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