feat: improve rpa video timing and narration
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 20s
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 20s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,13 +8,16 @@ import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from jiangchang_skill_core import config
|
||||
from jiangchang_skill_core.rpa import video_session as vs
|
||||
from jiangchang_skill_core.rpa.video_session import (
|
||||
RpaVideoSession,
|
||||
_StepEntry,
|
||||
_INTRO_BUFFER_SECONDS,
|
||||
_INTRO_STABILIZE_SECONDS,
|
||||
_OUTRO_BUFFER_SECONDS,
|
||||
_compose_capture_mp4,
|
||||
_music_fade_params,
|
||||
_select_voiceover_clips,
|
||||
@@ -225,15 +228,38 @@ class TestVoiceoverTextFilter(unittest.TestCase):
|
||||
|
||||
def test_min_gap_two_seconds(self) -> None:
|
||||
entries = [
|
||||
_StepEntry(0.0, "开始采集"),
|
||||
_StepEntry(1.0, "启动浏览器"),
|
||||
_StepEntry(0.0, "获取1688账号"),
|
||||
_StepEntry(1.0, "连接CDP"),
|
||||
_StepEntry(3.0, "检查登录状态"),
|
||||
]
|
||||
clips = _select_voiceover_clips(entries)
|
||||
self.assertEqual(len(clips), 2)
|
||||
self.assertEqual(clips[0][1], "开始采集")
|
||||
self.assertEqual(clips[0][1], "获取1688账号")
|
||||
self.assertEqual(clips[1][1], "检查登录状态")
|
||||
|
||||
def test_important_bypasses_min_gap(self) -> None:
|
||||
entries = [
|
||||
_StepEntry(0.0, "定位搜索框"),
|
||||
_StepEntry(0.5, "输入关键词:宁德电池"),
|
||||
_StepEntry(1.0, "点击搜索"),
|
||||
_StepEntry(1.5, "等待搜索结果"),
|
||||
]
|
||||
clips = _select_voiceover_clips(entries)
|
||||
texts = [t for _, t in clips]
|
||||
self.assertEqual(len(clips), 4)
|
||||
self.assertIn("输入关键词:宁德电池", texts)
|
||||
self.assertIn("点击搜索", texts)
|
||||
|
||||
def test_noise_still_skipped_with_important_nearby(self) -> None:
|
||||
entries = [
|
||||
_StepEntry(0.0, "点击搜索"),
|
||||
_StepEntry(0.5, "跳过重复店铺"),
|
||||
_StepEntry(1.0, "写入联系人库"),
|
||||
]
|
||||
clips = _select_voiceover_clips(entries)
|
||||
texts = [t for _, t in clips]
|
||||
self.assertEqual(texts, ["点击搜索"])
|
||||
|
||||
|
||||
class TestComposeCaptureMp4(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
@@ -343,6 +369,134 @@ class TestComposeCaptureMp4(unittest.TestCase):
|
||||
self.assertNotIn("-stream_loop", cmd)
|
||||
|
||||
|
||||
class TestVideoSessionLifecycle(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
config.reset_cache()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||
config.reset_cache()
|
||||
|
||||
@patch("jiangchang_skill_core.rpa.video_session.asyncio.sleep", new_callable=AsyncMock)
|
||||
@patch.object(RpaVideoSession, "_start_ffmpeg_capture")
|
||||
@patch.object(RpaVideoSession, "_stop_ffmpeg_capture")
|
||||
@patch.object(RpaVideoSession, "finalize", new_callable=AsyncMock)
|
||||
def test_enter_starts_capture_before_title(
|
||||
self,
|
||||
mock_finalize: AsyncMock,
|
||||
mock_stop: MagicMock,
|
||||
mock_start: MagicMock,
|
||||
mock_sleep: AsyncMock,
|
||||
) -> None:
|
||||
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||
call_order: list[str] = []
|
||||
mock_start.side_effect = lambda: call_order.append("start_capture")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config.reset_cache()
|
||||
session = RpaVideoSession(
|
||||
skill_slug="test",
|
||||
skill_data_dir=tmp,
|
||||
batch_id="batch_intro",
|
||||
title="开始执行测试",
|
||||
)
|
||||
original_add = session.add_step
|
||||
|
||||
def _track_add(text: str, *, duration: float = 4.0) -> None:
|
||||
call_order.append(f"add_step:{text}")
|
||||
original_add(text, duration=duration)
|
||||
|
||||
with patch.object(session, "add_step", side_effect=_track_add):
|
||||
asyncio.run(session.__aenter__())
|
||||
|
||||
self.assertEqual(call_order[0], "start_capture")
|
||||
self.assertIn("add_step:开始执行测试", call_order)
|
||||
mock_sleep.assert_awaited()
|
||||
total_sleep = sum(c.args[0] for c in mock_sleep.await_args_list if c.args)
|
||||
self.assertAlmostEqual(total_sleep, _INTRO_BUFFER_SECONDS)
|
||||
|
||||
@patch("jiangchang_skill_core.rpa.video_session.asyncio.sleep", new_callable=AsyncMock)
|
||||
@patch.object(RpaVideoSession, "_start_ffmpeg_capture")
|
||||
def test_enter_no_title_still_buffers(
|
||||
self,
|
||||
mock_start: MagicMock,
|
||||
mock_sleep: AsyncMock,
|
||||
) -> None:
|
||||
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config.reset_cache()
|
||||
session = RpaVideoSession(
|
||||
skill_slug="test",
|
||||
skill_data_dir=tmp,
|
||||
batch_id="batch_no_title",
|
||||
)
|
||||
with patch.object(session, "add_step") as mock_add:
|
||||
asyncio.run(session.__aenter__())
|
||||
mock_start.assert_called_once()
|
||||
mock_add.assert_not_called()
|
||||
total_sleep = sum(c.args[0] for c in mock_sleep.await_args_list if c.args)
|
||||
self.assertAlmostEqual(total_sleep, _INTRO_BUFFER_SECONDS)
|
||||
|
||||
@patch("jiangchang_skill_core.rpa.video_session.asyncio.sleep", new_callable=AsyncMock)
|
||||
@patch.object(RpaVideoSession, "_stop_ffmpeg_capture")
|
||||
@patch.object(RpaVideoSession, "finalize", new_callable=AsyncMock)
|
||||
def test_exit_closing_title_before_stop(
|
||||
self,
|
||||
mock_finalize: AsyncMock,
|
||||
mock_stop: MagicMock,
|
||||
mock_sleep: AsyncMock,
|
||||
) -> None:
|
||||
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||
call_order: list[str] = []
|
||||
mock_stop.side_effect = lambda: call_order.append("stop_capture")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config.reset_cache()
|
||||
session = RpaVideoSession(
|
||||
skill_slug="test",
|
||||
skill_data_dir=tmp,
|
||||
batch_id="batch_outro",
|
||||
closing_title="任务已完成",
|
||||
)
|
||||
original_add = session.add_step
|
||||
|
||||
def _track_add(text: str, *, duration: float = 4.0) -> None:
|
||||
call_order.append(f"add_step:{text}")
|
||||
original_add(text, duration=duration)
|
||||
|
||||
with patch.object(session, "add_step", side_effect=_track_add):
|
||||
asyncio.run(session.__aexit__(None, None, None))
|
||||
|
||||
self.assertIn("add_step:任务已完成", call_order)
|
||||
self.assertEqual(call_order[-1], "stop_capture")
|
||||
total_sleep = sum(c.args[0] for c in mock_sleep.await_args_list if c.args)
|
||||
self.assertAlmostEqual(total_sleep, _OUTRO_BUFFER_SECONDS)
|
||||
|
||||
@patch("jiangchang_skill_core.rpa.video_session.asyncio.sleep", new_callable=AsyncMock)
|
||||
@patch.object(RpaVideoSession, "_stop_ffmpeg_capture")
|
||||
@patch.object(RpaVideoSession, "finalize", new_callable=AsyncMock)
|
||||
def test_exit_no_closing_title_still_outro_buffer(
|
||||
self,
|
||||
mock_finalize: AsyncMock,
|
||||
mock_stop: MagicMock,
|
||||
mock_sleep: AsyncMock,
|
||||
) -> None:
|
||||
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config.reset_cache()
|
||||
session = RpaVideoSession(
|
||||
skill_slug="test",
|
||||
skill_data_dir=tmp,
|
||||
batch_id="batch_outro_empty",
|
||||
)
|
||||
with patch.object(session, "add_step") as mock_add:
|
||||
asyncio.run(session.__aexit__(None, None, None))
|
||||
mock_add.assert_not_called()
|
||||
mock_stop.assert_called_once()
|
||||
total_sleep = sum(c.args[0] for c in mock_sleep.await_args_list if c.args)
|
||||
self.assertAlmostEqual(total_sleep, _OUTRO_BUFFER_SECONDS)
|
||||
|
||||
|
||||
class TestFinalizeDegradation(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
config.reset_cache()
|
||||
|
||||
Reference in New Issue
Block a user