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)
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
"""P2 验收链路:mock 宿主上"导入 → 铺轨 → 切/重建 → 删除 → 加效果 → 取帧验证 →
|
||||
整段会话撤销"全链路自动化通过(计划文档 §4 P2 验收)。
|
||||
|
||||
关键断言:帧非全黑、编辑前后切口帧像素不同、undo_session 后时间线恢复原状。
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
from mock_host import MockHostAdapter, decode_png, frame_is_black
|
||||
from quercus_core.tools.executor import HostToolExecutor
|
||||
from quercus_core.types import Action, Rational
|
||||
|
||||
FRAME_SIZE = {"width": 64, "height": 36}
|
||||
|
||||
|
||||
def r(x: int) -> dict:
|
||||
return {"num": x, "den": 1}
|
||||
|
||||
|
||||
def _frame_at(executor, t_json, size=FRAME_SIZE):
|
||||
result = executor.execute(Action(tool="get_frame", params={"time": t_json, "max_size": size}))
|
||||
assert result.ok, f"get_frame 失败: {result.summary}"
|
||||
assert len(result.images) == 1
|
||||
return result.images[0]
|
||||
|
||||
|
||||
def _sha(png: bytes) -> str:
|
||||
return hashlib.sha256(png).hexdigest()
|
||||
|
||||
|
||||
def _first_clips(adapter):
|
||||
seq_id = adapter.get_project_overview().timeline_ids[0]
|
||||
return adapter.get_timeline_structure(seq_id).tracks[0].clips
|
||||
|
||||
|
||||
def test_p2_acceptance_chain():
|
||||
adapter = MockHostAdapter()
|
||||
executor = HostToolExecutor(adapter)
|
||||
|
||||
def act(tool, **params):
|
||||
result = executor.execute(Action(tool=tool, params=params))
|
||||
assert result.ok, f"{tool} 失败: {result.summary}"
|
||||
return result
|
||||
|
||||
# 1) 导入两个素材
|
||||
act("import_footage", paths=["/media/alpha.mp4", "/media/beta.mp4"])
|
||||
footage = {it["name"]: it["id"] for it in adapter.list_footage()}
|
||||
f_alpha = footage["alpha.mp4"]
|
||||
f_beta = footage["beta.mp4"]
|
||||
|
||||
# 2) 铺轨:alpha[0,4) + beta[4,8)
|
||||
act("place_clip", clip_id=f_alpha, track_index=1, time=r(0), in_point=r(0), out_point=r(4))
|
||||
act("place_clip", clip_id=f_beta, track_index=1, time=r(4), in_point=r(0), out_point=r(4))
|
||||
clip_a = _first_clips(adapter)[0]
|
||||
|
||||
# 3) 切口帧(编辑前):t=3 处是 alpha 的画面
|
||||
frame_before = _frame_at(executor, r(3))
|
||||
assert not frame_is_black(frame_before, 64, 36), "编辑前切口帧不得全黑"
|
||||
decode_png(frame_before) # 必须是合法 PNG
|
||||
|
||||
# 4) 在 t=2 处切开 alpha
|
||||
act("split_clip", clip_id=clip_a.id, time=r(2))
|
||||
right_half = next(c for c in _first_clips(adapter) if c.start == Rational(2))
|
||||
|
||||
# 5) 波纹删除右半段 → beta 前移到 [2,6)
|
||||
act("ripple_delete", clip_ids=[right_half.id])
|
||||
frame_after_cut = _frame_at(executor, r(3))
|
||||
assert not frame_is_black(frame_after_cut, 64, 36)
|
||||
# 编辑前后切口帧像素不同(beta 代替了 alpha 出现在 t=3)
|
||||
assert _sha(frame_after_cut) != _sha(frame_before), "切口帧像素应随编辑变化"
|
||||
|
||||
# 6) 给 beta 加效果并设参数 → 画面再变
|
||||
beta_clip = next(c for c in _first_clips(adapter) if c.source == f_beta)
|
||||
act("add_effect", clip_id=beta_clip.id, effect_type="brightness")
|
||||
node_id = adapter._must_clip(beta_clip.id).effects[0].node_id # 白盒取节点
|
||||
act("set_param", effect_id=node_id, param="level", value=0.5)
|
||||
frame_after_effect = _frame_at(executor, r(3))
|
||||
assert not frame_is_black(frame_after_effect, 64, 36)
|
||||
assert _sha(frame_after_effect) != _sha(frame_after_cut), "加效果后画面应改变"
|
||||
|
||||
# 7) 整段会话撤销 → 时间线恢复原状(回到首个变更批次之前:空)
|
||||
restored = executor.undo_session()
|
||||
assert restored >= 6, f"应撤销 ≥6 个批次,实际 {restored}"
|
||||
overview = adapter.get_project_overview()
|
||||
assert overview.timeline_ids == [] # 无时间线 = 铺轨前的原状
|
||||
assert adapter.list_footage() == [] # 连导入也被撤销(快照覆盖首个批次)
|
||||
@@ -0,0 +1,363 @@
|
||||
"""工具适配层测试:26 个工具 handler 在 mock 宿主上逐个过一遍 + get_params 回填。
|
||||
|
||||
每个工具至少一个用例;变更类工具经 executor 走 adapter.execute 快照批次。
|
||||
"""
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from mock_host import MockHostAdapter, decode_png
|
||||
from quercus_core.tools.executor import HostToolExecutor
|
||||
from quercus_core.tools.schemas import TOOL_LIST, TOOLS
|
||||
from quercus_core.types import Action, ActionBatch, Rational
|
||||
|
||||
|
||||
def r(x: int) -> dict:
|
||||
return {"num": x, "den": 1}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh():
|
||||
"""预铺好的 mock 宿主 + 执行器(alpha[0,4) + beta[4,8) 在轨道 1)。"""
|
||||
adapter = MockHostAdapter()
|
||||
executor = HostToolExecutor(adapter)
|
||||
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)},
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
return adapter, executor
|
||||
|
||||
|
||||
def _seq(adapter):
|
||||
return adapter.get_project_overview().timeline_ids[0]
|
||||
|
||||
|
||||
def _clips(adapter):
|
||||
return adapter.get_timeline_structure(_seq(adapter)).tracks[0].clips
|
||||
|
||||
|
||||
def _exec(executor, tool, **params):
|
||||
return executor.execute(Action(tool=tool, params=params))
|
||||
|
||||
|
||||
def test_all_26_tools_registered():
|
||||
assert len(TOOLS) == 26
|
||||
assert len(TOOL_LIST) == 26
|
||||
|
||||
|
||||
# ---- 工程 ----
|
||||
|
||||
def test_open_project(fresh):
|
||||
adapter, executor = fresh
|
||||
result = _exec(executor, "open_project", path="/tmp/demo.qproj")
|
||||
assert result.ok
|
||||
assert "已打开" in result.summary
|
||||
assert adapter.get_project_overview().name == "demo"
|
||||
|
||||
|
||||
def test_save_project(fresh):
|
||||
adapter, executor = fresh
|
||||
result = _exec(executor, "save_project", path="/tmp/demo.qproj")
|
||||
assert result.ok
|
||||
assert adapter._state.project.path == "/tmp/demo.qproj"
|
||||
assert adapter._state.project.modified is False
|
||||
|
||||
|
||||
def test_get_project_overview(fresh):
|
||||
adapter, executor = fresh
|
||||
result = _exec(executor, "get_project_overview")
|
||||
assert result.ok
|
||||
assert "工程:" in result.summary
|
||||
assert "时间线" in result.summary
|
||||
|
||||
|
||||
# ---- 媒体 ----
|
||||
|
||||
def test_probe_media(fresh):
|
||||
adapter, executor = fresh
|
||||
result = _exec(executor, "probe_media", path="/media/alpha.mp4")
|
||||
assert result.ok
|
||||
assert "分辨率" in result.summary
|
||||
|
||||
|
||||
def test_import_footage(fresh):
|
||||
adapter, executor = fresh
|
||||
result = _exec(executor, "import_footage", paths=["/media/gamma.mp4"])
|
||||
assert result.ok
|
||||
assert any("gamma" in it["path"] for it in adapter.list_footage())
|
||||
|
||||
|
||||
def test_list_footage(fresh):
|
||||
adapter, executor = fresh
|
||||
result = _exec(executor, "list_footage")
|
||||
assert result.ok
|
||||
assert "2" in result.summary
|
||||
|
||||
|
||||
# ---- 时间线 ----
|
||||
|
||||
def test_add_track(fresh):
|
||||
adapter, executor = fresh
|
||||
result = _exec(executor, "add_track", type="audio")
|
||||
assert result.ok
|
||||
tl = adapter.get_timeline_structure(_seq(adapter))
|
||||
assert any(t.kind == "audio" for t in tl.tracks)
|
||||
|
||||
|
||||
def test_place_clip(fresh):
|
||||
adapter, executor = fresh
|
||||
fid = adapter.list_footage()[0]["id"]
|
||||
result = _exec(executor, "place_clip", clip_id=fid, track_index=2, time=r(0), in_point=r(0), out_point=r(2))
|
||||
assert result.ok
|
||||
assert "已放置" in result.summary
|
||||
tl = adapter.get_timeline_structure(_seq(adapter))
|
||||
placed = [c for t in tl.tracks if t.index == 2 for c in t.clips]
|
||||
assert len(placed) == 1 and placed[0].duration == Rational(2)
|
||||
|
||||
|
||||
def test_split_clip(fresh):
|
||||
adapter, executor = fresh
|
||||
clip_id = _clips(adapter)[0].id
|
||||
result = _exec(executor, "split_clip", clip_id=clip_id, time=r(2))
|
||||
assert result.ok
|
||||
assert len(_clips(adapter)) == 3 # 左右两段 + beta
|
||||
|
||||
|
||||
def test_trim_clip(fresh):
|
||||
adapter, executor = fresh
|
||||
clip_id = _clips(adapter)[0].id
|
||||
result = _exec(executor, "trim_clip", clip_id=clip_id, in_point=r(1), duration=r(2))
|
||||
assert result.ok
|
||||
clip = next(c for c in _clips(adapter) if c.id == clip_id)
|
||||
assert clip.duration == Rational(2)
|
||||
assert clip.in_point == Rational(1)
|
||||
|
||||
|
||||
def test_move_clip(fresh):
|
||||
adapter, executor = fresh
|
||||
clip_id = _clips(adapter)[0].id
|
||||
result = _exec(executor, "move_clip", clip_id=clip_id, track_index=2, time=r(1))
|
||||
assert result.ok
|
||||
tl = adapter.get_timeline_structure(_seq(adapter))
|
||||
moved = [c for t in tl.tracks if t.index == 2 for c in t.clips]
|
||||
assert len(moved) == 1 and moved[0].start == Rational(1)
|
||||
|
||||
|
||||
def test_ripple_delete(fresh):
|
||||
adapter, executor = fresh
|
||||
clip_a = _clips(adapter)[0].id
|
||||
result = _exec(executor, "ripple_delete", clip_ids=[clip_a])
|
||||
assert result.ok
|
||||
clips = _clips(adapter)
|
||||
assert len(clips) == 1
|
||||
assert clips[0].start == Rational(0) # beta 前移到开头(空隙闭合)
|
||||
|
||||
|
||||
def test_add_transition(fresh):
|
||||
adapter, executor = fresh
|
||||
clip_id = _clips(adapter)[0].id
|
||||
result = _exec(executor, "add_transition", clip_id=clip_id, transition_type="cross_dissolve", duration=r(1))
|
||||
assert result.ok
|
||||
assert "cross_dissolve" in result.summary
|
||||
|
||||
|
||||
def test_add_marker(fresh):
|
||||
adapter, executor = fresh
|
||||
result = _exec(executor, "add_marker", time=r(1), label="打点", color="red", custom_data={"ai": True})
|
||||
assert result.ok
|
||||
seq = adapter._must_seq(_seq(adapter))
|
||||
assert len(seq.markers) == 1 and seq.markers[0].label == "打点"
|
||||
|
||||
|
||||
# ---- 效果 / 关键帧 ----
|
||||
|
||||
def test_add_effect(fresh):
|
||||
adapter, executor = fresh
|
||||
clip_id = _clips(adapter)[0].id
|
||||
result = _exec(executor, "add_effect", clip_id=clip_id, effect_type="brightness")
|
||||
assert result.ok
|
||||
assert "brightness" in result.summary
|
||||
assert len(adapter._must_clip(clip_id).effects) == 1
|
||||
|
||||
|
||||
def test_set_param(fresh):
|
||||
adapter, executor = fresh
|
||||
clip_id = _clips(adapter)[0].id
|
||||
_exec(executor, "add_effect", clip_id=clip_id, effect_type="brightness")
|
||||
node = adapter._must_clip(clip_id).effects[0].node_id
|
||||
result = _exec(executor, "set_param", effect_id=node, param="level", value=0.5)
|
||||
assert result.ok
|
||||
assert adapter._must_effect(node).params["level"] == 0.5
|
||||
|
||||
|
||||
def test_set_keyframe(fresh):
|
||||
adapter, executor = fresh
|
||||
clip_id = _clips(adapter)[0].id
|
||||
_exec(executor, "add_effect", clip_id=clip_id, effect_type="brightness")
|
||||
node = adapter._must_clip(clip_id).effects[0].node_id
|
||||
result = _exec(executor, "set_keyframe", effect_id=node, param="level", time=r(1), value=0.8)
|
||||
assert result.ok
|
||||
assert adapter._must_effect(node).keyframes["level"][0].value == 0.8
|
||||
|
||||
|
||||
def test_list_effects_available_and_on_clip(fresh):
|
||||
adapter, executor = fresh
|
||||
result = _exec(executor, "list_effects")
|
||||
assert result.ok
|
||||
assert "brightness" in result.summary
|
||||
clip_id = _clips(adapter)[0].id
|
||||
_exec(executor, "add_effect", clip_id=clip_id, effect_type="crop")
|
||||
result2 = _exec(executor, "list_effects", clip_id=clip_id)
|
||||
assert result2.ok
|
||||
assert "crop" in result2.summary
|
||||
|
||||
|
||||
# ---- 取帧 ----
|
||||
|
||||
def test_get_frame(fresh):
|
||||
adapter, executor = fresh
|
||||
result = _exec(executor, "get_frame", time=r(1), max_size={"width": 64, "height": 36})
|
||||
assert result.ok
|
||||
assert len(result.images) == 1
|
||||
assert result.images[0].startswith(b"\x89PNG\r\n\x1a\n")
|
||||
decode_png(result.images[0]) # 合法 PNG
|
||||
|
||||
|
||||
def test_scan_timeline(fresh):
|
||||
adapter, executor = fresh
|
||||
result = _exec(executor, "scan_timeline", range={"start": r(0), "end": r(4)}, count=4)
|
||||
assert result.ok
|
||||
assert len(result.images) == 4
|
||||
for img in result.images:
|
||||
decode_png(img)
|
||||
|
||||
|
||||
def test_get_audio_levels(fresh):
|
||||
adapter, executor = fresh
|
||||
result = _exec(executor, "get_audio_levels", range={"start": r(0), "end": r(4)}, resolution=16)
|
||||
assert result.ok
|
||||
assert "电平" in result.summary
|
||||
|
||||
|
||||
# ---- 回放 ----
|
||||
|
||||
def test_play_pause_seek(fresh):
|
||||
adapter, executor = fresh
|
||||
assert _exec(executor, "play").ok
|
||||
assert _exec(executor, "seek", time=r(2)).ok
|
||||
state = adapter.get_state()
|
||||
assert state.playing is True and state.position == Rational(2)
|
||||
assert _exec(executor, "pause").ok
|
||||
assert adapter.get_state().playing is False
|
||||
|
||||
|
||||
# ---- 导出 / 撤销 ----
|
||||
|
||||
def test_export_render(fresh):
|
||||
adapter, executor = fresh
|
||||
result = _exec(executor, "export_render", output="/tmp/out.mp4", preset="h264")
|
||||
assert result.ok
|
||||
assert "导出" in result.summary
|
||||
|
||||
|
||||
def test_undo_last_action(fresh):
|
||||
adapter, executor = fresh
|
||||
before = len(_clips(adapter))
|
||||
_exec(executor, "add_marker", time=r(1))
|
||||
result = _exec(executor, "undo_last_action")
|
||||
assert result.ok
|
||||
assert len(_clips(adapter)) == before # 快照恢复到 add_marker 前
|
||||
assert "撤销" in result.summary
|
||||
|
||||
|
||||
# ---- get_params 回填(min/max/choices 供未来 schema 回填) ----
|
||||
|
||||
def test_get_params_backfill(fresh):
|
||||
adapter, executor = fresh
|
||||
brightness = executor.get_params("brightness")
|
||||
level = next(p for p in brightness if p["name"] == "level")
|
||||
assert level["min"] == -1.0 and level["max"] == 1.0
|
||||
blend = next(p for p in executor.get_params("overlay") if p["name"] == "blend_mode")
|
||||
assert blend["choices"] == ["normal", "screen", "multiply"]
|
||||
assert executor.get_params("no_such") == [] # 未知效果类型优雅降级
|
||||
|
||||
|
||||
def test_set_param_rejects_invalid_choice(fresh):
|
||||
adapter, executor = fresh
|
||||
clip_id = _clips(adapter)[0].id
|
||||
_exec(executor, "add_effect", clip_id=clip_id, effect_type="overlay")
|
||||
node = adapter._must_clip(clip_id).effects[0].node_id
|
||||
result = _exec(executor, "set_param", effect_id=node, param="blend_mode", value="blur")
|
||||
assert result.ok is False
|
||||
assert "非法" in result.summary or "失败" in result.summary
|
||||
|
||||
|
||||
# ---- 批级执行(一个批次一个快照) ----
|
||||
|
||||
def test_execute_batch_single_snapshot(fresh):
|
||||
adapter, executor = fresh
|
||||
before = len(executor.list_snapshots())
|
||||
batch = ActionBatch(
|
||||
label="two-markers",
|
||||
session_id=executor.session_id,
|
||||
actions=[
|
||||
Action(tool="add_marker", params={"time": r(1), "label": "m1"}),
|
||||
Action(tool="add_marker", params={"time": r(2), "label": "m2"}),
|
||||
],
|
||||
)
|
||||
results = executor.execute_batch(batch)
|
||||
assert all(res.ok for res in results)
|
||||
assert len(executor.list_snapshots()) == before + 1 # 一批次一个快照
|
||||
|
||||
|
||||
# ---- AgentLoop 集成:mock LLM 派发到 executor ----
|
||||
|
||||
def test_agent_loop_with_host_executor(fresh):
|
||||
from mock_llm import MockProvider
|
||||
from quercus_core.agent.loop import AgentLoop
|
||||
from quercus_core.providers.base import AssistantTurn, ToolCall
|
||||
|
||||
adapter, executor = fresh
|
||||
fid = adapter.list_footage()[0]["id"]
|
||||
mock = MockProvider(
|
||||
script=[
|
||||
AssistantTurn(
|
||||
text="",
|
||||
tool_calls=(
|
||||
ToolCall(
|
||||
id="c1",
|
||||
name="add_marker",
|
||||
arguments={"time": {"num": 3, "den": 1}, "label": "AI 打点"},
|
||||
),
|
||||
ToolCall(
|
||||
id="c2",
|
||||
name="get_frame",
|
||||
arguments={"time": {"num": 1, "den": 1}, "max_size": {"width": 64, "height": 36}},
|
||||
),
|
||||
),
|
||||
),
|
||||
AssistantTurn(text="完成", tool_calls=()),
|
||||
]
|
||||
)
|
||||
loop = AgentLoop(provider=mock, executor=executor, confirm=lambda batch: True)
|
||||
result = loop.run("在 3 秒处打点并看一眼画面")
|
||||
assert result.turns == 2
|
||||
seq = adapter._must_seq(_seq(adapter))
|
||||
assert any(m.label == "AI 打点" for m in seq.markers) # 变更已生效
|
||||
# get_frame 的 PNG 回喂给 provider
|
||||
last = mock.observed[-1]
|
||||
tool_msgs = [m for m in last if m.role == "tool"]
|
||||
frame_msg = next(m for m in tool_msgs if m.name == "get_frame")
|
||||
assert len(frame_msg.images) == 1
|
||||
@@ -0,0 +1,112 @@
|
||||
"""取帧流水线测试: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))
|
||||
@@ -0,0 +1,267 @@
|
||||
"""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
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Web 面板测试:HTTP/SSE 端点的集成路径(confirm approve/reject 经端点走通)。
|
||||
|
||||
- confirm approve / reject 流程经 HTTP 端点裁决;
|
||||
- undo_session / 快照管理端点;
|
||||
- SSE 事件流(用真实 uvicorn 起服务走 TCP,TestClient 会缓冲无限流);
|
||||
- EventBroker 单元测试。
|
||||
本模块依赖可选 extra ``core[webui]``,未安装时整模块 skip。
|
||||
"""
|
||||
import http.client
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
fastapi = pytest.importorskip("fastapi")
|
||||
import uvicorn # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from mock_host import MockHostAdapter # noqa: E402
|
||||
from mock_llm import MockProvider # noqa: E402
|
||||
from quercus_core.providers.base import AssistantTurn, ToolCall # noqa: E402
|
||||
from quercus_core.types import Action # noqa: E402
|
||||
from quercus_core.webui.app import create_app # noqa: E402
|
||||
from quercus_core.webui.broker import EventBroker # noqa: E402
|
||||
|
||||
|
||||
def _wait_pending(client, timeout=5.0):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
data = client.get("/api/pending").json()
|
||||
if data["pending"]:
|
||||
return data["pending"][0]
|
||||
time.sleep(0.02)
|
||||
return None
|
||||
|
||||
|
||||
# ---- 基础端点 ----
|
||||
|
||||
def test_index_served():
|
||||
app = create_app(adapter=MockHostAdapter(), provider=MockProvider(script=[]))
|
||||
client = TestClient(app)
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert "<html" in r.text
|
||||
|
||||
|
||||
def test_status_endpoint():
|
||||
app = create_app(adapter=MockHostAdapter(), provider=MockProvider(script=[]))
|
||||
client = TestClient(app)
|
||||
data = client.get("/api/status").json()
|
||||
assert data["ready"] is True
|
||||
|
||||
|
||||
# ---- confirm approve / reject(必须的验收路径) ----
|
||||
|
||||
def test_confirm_approve_flow():
|
||||
adapter = MockHostAdapter()
|
||||
provider = MockProvider(
|
||||
script=[
|
||||
AssistantTurn(
|
||||
text="",
|
||||
tool_calls=(ToolCall(id="c1", name="import_footage", arguments={"paths": ["/media/a.mp4"]}),),
|
||||
),
|
||||
AssistantTurn(text="导入完成", tool_calls=()),
|
||||
]
|
||||
)
|
||||
app = create_app(adapter=adapter, provider=provider, confirm_timeout=10)
|
||||
client = TestClient(app)
|
||||
assert client.post("/api/chat", json={"text": "导入素材 a"}).json()["ok"] is True
|
||||
|
||||
batch = _wait_pending(client)
|
||||
assert batch is not None
|
||||
assert batch["actions"][0]["tool"] == "import_footage"
|
||||
|
||||
r = client.post(f"/api/confirm/{batch['batch_id']}", json={"approved": True})
|
||||
assert r.status_code == 200 and r.json()["ok"] is True
|
||||
assert app.state.session.wait_done(10)
|
||||
assert len(adapter.list_footage()) == 1 # 确认后变更已执行
|
||||
|
||||
|
||||
def test_confirm_reject_flow():
|
||||
adapter = MockHostAdapter()
|
||||
provider = MockProvider(
|
||||
script=[
|
||||
AssistantTurn(
|
||||
text="",
|
||||
tool_calls=(ToolCall(id="c1", name="import_footage", arguments={"paths": ["/media/a.mp4"]}),),
|
||||
),
|
||||
AssistantTurn(text="已取消", tool_calls=()),
|
||||
]
|
||||
)
|
||||
app = create_app(adapter=adapter, provider=provider, confirm_timeout=10)
|
||||
client = TestClient(app)
|
||||
client.post("/api/chat", json={"text": "导入素材"})
|
||||
batch = _wait_pending(client)
|
||||
assert batch is not None
|
||||
|
||||
r = client.post(f"/api/confirm/{batch['batch_id']}", json={"approved": False})
|
||||
assert r.json()["ok"] is True
|
||||
assert app.state.session.wait_done(10)
|
||||
assert adapter.list_footage() == [] # 拒绝后未执行
|
||||
|
||||
|
||||
def test_confirm_unknown_batch_rejected():
|
||||
app = create_app(adapter=MockHostAdapter(), provider=MockProvider(script=[]))
|
||||
client = TestClient(app)
|
||||
r = client.post("/api/confirm/no_such_batch", json={"approved": True})
|
||||
assert r.json()["ok"] is False
|
||||
|
||||
|
||||
def test_chat_busy_guard():
|
||||
"""上一轮对话(待确认阻塞中)未结束前,拒绝新的发消息请求。"""
|
||||
provider = MockProvider(
|
||||
script=[AssistantTurn(text="", tool_calls=(ToolCall(id="c1", name="import_footage", arguments={"paths": ["/media/a.mp4"]}),))]
|
||||
)
|
||||
app = create_app(adapter=MockHostAdapter(), provider=provider, confirm_timeout=30)
|
||||
client = TestClient(app)
|
||||
assert client.post("/api/chat", json={"text": "第一条"}).json()["ok"] is True
|
||||
assert _wait_pending(client) is not None # 会话被待确认阻塞
|
||||
r = client.post("/api/chat", json={"text": "第二条"})
|
||||
assert r.json()["ok"] is False
|
||||
assert "进行" in r.json()["reason"]
|
||||
|
||||
|
||||
# ---- undo_session / 快照管理端点 ----
|
||||
|
||||
def test_undo_session_endpoint():
|
||||
adapter = MockHostAdapter()
|
||||
app = create_app(adapter=adapter, provider=MockProvider(script=[]))
|
||||
client = TestClient(app)
|
||||
app.state.session.executor.execute(Action(tool="import_footage", params={"paths": ["/media/a.mp4"]}))
|
||||
r = client.post("/api/undo_session")
|
||||
data = r.json()
|
||||
assert data["ok"] is True and data["restored"] >= 1
|
||||
assert adapter.list_footage() == [] # 时间线/媒体恢复原状
|
||||
|
||||
|
||||
def test_snapshot_endpoints():
|
||||
adapter = MockHostAdapter()
|
||||
app = create_app(adapter=adapter, provider=MockProvider(script=[]))
|
||||
client = TestClient(app)
|
||||
app.state.session.executor.execute(Action(tool="import_footage", params={"paths": ["/media/a.mp4"]}))
|
||||
snaps = client.get("/api/snapshots").json()["snapshots"]
|
||||
assert len(snaps) >= 1
|
||||
sid = snaps[0]["id"]
|
||||
assert client.post(f"/api/snapshots/{sid}/drop").json()["ok"] is True
|
||||
assert client.get("/api/snapshots").json()["snapshots"] == []
|
||||
|
||||
|
||||
# ---- EventBroker 单元 ----
|
||||
|
||||
def test_event_broker():
|
||||
broker = EventBroker()
|
||||
q1 = broker.subscribe()
|
||||
q2 = broker.subscribe()
|
||||
broker.publish({"kind": "system", "text": "hi"})
|
||||
assert q1.get(timeout=1)["text"] == "hi"
|
||||
assert q2.get(timeout=1)["text"] == "hi"
|
||||
broker.unsubscribe(q1)
|
||||
assert broker.subscriber_count == 1
|
||||
|
||||
|
||||
# ---- SSE 事件流(真实 uvicorn 走 TCP) ----
|
||||
|
||||
@pytest.fixture
|
||||
def live_server():
|
||||
adapter = MockHostAdapter()
|
||||
app = create_app(adapter=adapter, provider=MockProvider(script=[]))
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
|
||||
server = uvicorn.Server(config)
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
pytest.fail("uvicorn 未在超时内就绪")
|
||||
yield {"app": app, "port": port}
|
||||
server.should_exit = True
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_sse_stream_delivers_events(live_server):
|
||||
port = live_server["port"]
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
try:
|
||||
conn.request("GET", "/api/events")
|
||||
resp = conn.getresponse()
|
||||
assert resp.status == 200
|
||||
assert resp.getheader("content-type", "").startswith("text/event-stream")
|
||||
first = resp.readline()
|
||||
assert b"connected" in first # 首条事件确认流已就绪
|
||||
|
||||
# 发布一条真实事件(undo_session 会发 system 消息)
|
||||
live_server["app"].state.session.undo_session()
|
||||
got = None
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
line = resp.readline()
|
||||
if not line:
|
||||
break
|
||||
if line.startswith(b"data: "):
|
||||
got = json.loads(line[6:])
|
||||
break
|
||||
assert got is not None
|
||||
assert got["kind"] == "system"
|
||||
finally:
|
||||
conn.close()
|
||||
Reference in New Issue
Block a user