"""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