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