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