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

113 lines
4.2 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.
"""取帧流水线测试: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))