465 lines
17 KiB
Python
465 lines
17 KiB
Python
import io
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from contextlib import redirect_stdout
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
from uuid import uuid4
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
|
|
import cli
|
|
|
|
|
|
class TestCli(unittest.TestCase):
|
|
def test_default_voice_is_valid_edge_tts_voice(self):
|
|
args = cli.parse_args(["--video-subject", "测试主题"])
|
|
params = cli.build_video_params(args)
|
|
|
|
self.assertEqual(params.voice_name, "zh-CN-XiaoxiaoNeural-Female")
|
|
|
|
def test_complete_script_can_replace_video_subject(self):
|
|
args = cli.parse_args(["--video-script", "完整的视频文案"])
|
|
params = cli.build_video_params(args)
|
|
|
|
self.assertEqual(params.video_subject, "")
|
|
self.assertEqual(params.video_script, "完整的视频文案")
|
|
|
|
def test_subject_or_script_is_required(self):
|
|
with self.assertRaises(SystemExit) as cm:
|
|
cli.parse_args([])
|
|
|
|
self.assertEqual(cm.exception.code, 2)
|
|
|
|
def test_build_video_params_with_local_materials(self):
|
|
args = cli.parse_args(
|
|
[
|
|
"--video-subject",
|
|
"测试主题",
|
|
"--video-source",
|
|
"local",
|
|
"--video-materials",
|
|
"a.mp4, ,b.jpg,",
|
|
"--video-terms",
|
|
"foo, bar",
|
|
]
|
|
)
|
|
|
|
params = cli.build_video_params(args)
|
|
materials = params.video_materials
|
|
|
|
self.assertEqual(params.video_subject, "测试主题")
|
|
self.assertEqual(params.video_source, "local")
|
|
self.assertEqual([m.url for m in materials], ["a.mp4", "b.jpg"])
|
|
self.assertTrue(all(m.provider == "local" for m in materials))
|
|
self.assertEqual(params.video_terms, ["foo", "bar"])
|
|
|
|
def test_run_cli_dispatches_task_start(self):
|
|
with patch("app.services.task.start", return_value={"script": "ok"}) as start, patch(
|
|
"app.utils.utils.get_uuid", return_value="task-123"
|
|
), patch("builtins.print") as print_mock:
|
|
code = cli.run_cli(["--video-subject", "命令行测试", "--stop-at", "script"])
|
|
|
|
self.assertEqual(code, 0)
|
|
self.assertTrue(start.called)
|
|
kwargs = start.call_args.kwargs
|
|
self.assertEqual(kwargs["task_id"], "task-123")
|
|
self.assertEqual(kwargs["stop_at"], "script")
|
|
self.assertEqual(kwargs["params"].video_subject, "命令行测试")
|
|
print_mock.assert_called_once()
|
|
|
|
def test_run_cli_returns_error_when_task_fails(self):
|
|
with patch("app.services.task.start", return_value=None), patch(
|
|
"app.utils.utils.get_uuid", return_value="task-456"
|
|
), patch.object(cli.logger, "error") as log_error:
|
|
code = cli.run_cli(["--video-subject", "失败场景"])
|
|
|
|
self.assertEqual(code, 1)
|
|
log_error.assert_called_once()
|
|
|
|
def test_subtitle_enabled_by_default(self):
|
|
args = cli.parse_args(["--video-subject", "test"])
|
|
params = cli.build_video_params(args)
|
|
self.assertTrue(params.subtitle_enabled)
|
|
|
|
def test_subtitle_disabled_with_no_flag(self):
|
|
args = cli.parse_args(["--video-subject", "test", "--no-subtitle-enabled"])
|
|
params = cli.build_video_params(args)
|
|
self.assertFalse(params.subtitle_enabled)
|
|
|
|
def test_coverr_video_source_accepted(self):
|
|
args = cli.parse_args(["--video-subject", "test", "--video-source", "coverr"])
|
|
params = cli.build_video_params(args)
|
|
self.assertEqual(params.video_source, "coverr")
|
|
|
|
def test_build_video_params_with_script_video_and_audio_options(self):
|
|
args = cli.parse_args(
|
|
[
|
|
"--video-subject",
|
|
"test",
|
|
"--video-language",
|
|
"en",
|
|
"--paragraph-number",
|
|
"3",
|
|
"--video-script-prompt",
|
|
"use a lighter tone",
|
|
"--custom-system-prompt",
|
|
"write concise short-form scripts",
|
|
"--video-concat-mode",
|
|
"sequential",
|
|
"--video-transition-mode",
|
|
"fade-in",
|
|
"--video-clip-duration",
|
|
"4",
|
|
"--match-materials-to-script",
|
|
"--voice-volume",
|
|
"1.2",
|
|
"--voice-rate",
|
|
"1.1",
|
|
"--bgm-type",
|
|
"custom",
|
|
"--bgm-file",
|
|
"output001.mp3",
|
|
"--bgm-volume",
|
|
"0.3",
|
|
"--n-threads",
|
|
"4",
|
|
]
|
|
)
|
|
|
|
params = cli.build_video_params(args)
|
|
|
|
self.assertEqual(params.video_language, "en")
|
|
self.assertEqual(params.paragraph_number, 3)
|
|
self.assertEqual(params.video_script_prompt, "use a lighter tone")
|
|
self.assertEqual(params.custom_system_prompt, "write concise short-form scripts")
|
|
self.assertEqual(params.video_concat_mode, "sequential")
|
|
self.assertEqual(params.video_transition_mode, "FadeIn")
|
|
self.assertEqual(params.video_clip_duration, 4)
|
|
self.assertTrue(params.match_materials_to_script)
|
|
self.assertEqual(params.voice_volume, 1.2)
|
|
self.assertEqual(params.voice_rate, 1.1)
|
|
self.assertEqual(params.bgm_type, "custom")
|
|
self.assertEqual(params.bgm_file, "output001.mp3")
|
|
self.assertEqual(params.bgm_volume, 0.3)
|
|
self.assertEqual(params.n_threads, 4)
|
|
|
|
def test_custom_audio_file_maps_to_video_params(self):
|
|
args = cli.parse_args(
|
|
[
|
|
"--video-subject",
|
|
"test",
|
|
"--custom-audio-file",
|
|
"voiceover.mp3",
|
|
]
|
|
)
|
|
params = cli.build_video_params(args)
|
|
|
|
self.assertEqual(params.custom_audio_file, "voiceover.mp3")
|
|
|
|
def test_build_video_params_with_subtitle_style_options(self):
|
|
args = cli.parse_args(
|
|
[
|
|
"--video-subject",
|
|
"test",
|
|
"--font-name",
|
|
"MicrosoftYaHeiBold.ttc",
|
|
"--subtitle-position",
|
|
"custom",
|
|
"--custom-position",
|
|
"42.5",
|
|
"--text-fore-color",
|
|
"#AABBCC",
|
|
"--font-size",
|
|
"72",
|
|
"--stroke-color",
|
|
"#112233",
|
|
"--stroke-width",
|
|
"2.5",
|
|
"--subtitle-background-color",
|
|
"#000001",
|
|
"--rounded-subtitle-background",
|
|
]
|
|
)
|
|
|
|
params = cli.build_video_params(args)
|
|
|
|
self.assertEqual(params.font_name, "MicrosoftYaHeiBold.ttc")
|
|
self.assertEqual(params.subtitle_position, "custom")
|
|
self.assertEqual(params.custom_position, 42.5)
|
|
self.assertEqual(params.text_fore_color, "#AABBCC")
|
|
self.assertEqual(params.font_size, 72)
|
|
self.assertEqual(params.stroke_color, "#112233")
|
|
self.assertEqual(params.stroke_width, 2.5)
|
|
self.assertEqual(params.text_background_color, "#000001")
|
|
self.assertTrue(params.rounded_subtitle_background)
|
|
|
|
def test_disabled_subtitle_background_rejects_rounding(self):
|
|
with self.assertRaises(SystemExit) as cm:
|
|
cli.parse_args(
|
|
[
|
|
"--video-subject",
|
|
"test",
|
|
"--no-subtitle-background-enabled",
|
|
"--rounded-subtitle-background",
|
|
]
|
|
)
|
|
|
|
self.assertEqual(cm.exception.code, 2)
|
|
|
|
def test_bgm_type_none_maps_to_disabled_background_music(self):
|
|
args = cli.parse_args(["--video-subject", "test", "--bgm-type", "none"])
|
|
params = cli.build_video_params(args)
|
|
self.assertEqual(params.bgm_type, "")
|
|
|
|
def test_local_material_filename_resolved_to_absolute_path(self):
|
|
"""After preprocess_video, material.url should be an absolute path, not a bare filename."""
|
|
import os
|
|
import tempfile
|
|
from app.utils import utils
|
|
from app.services import video as vd
|
|
from app.models.schema import MaterialInfo
|
|
|
|
local_videos_dir = utils.storage_dir("local_videos", create=True)
|
|
# Create a minimal valid video file for testing
|
|
test_filename = "_cli_test_resolve.mp4"
|
|
test_filepath = os.path.join(local_videos_dir, test_filename)
|
|
# We need a real video file; use a tiny one via moviepy
|
|
try:
|
|
from moviepy import ColorClip
|
|
clip = ColorClip(size=(640, 640), color=(0, 0, 0), duration=1)
|
|
clip.write_videofile(test_filepath, fps=1, logger=None)
|
|
clip.close()
|
|
except Exception:
|
|
self.skipTest("moviepy not available for creating test video")
|
|
|
|
try:
|
|
materials = [MaterialInfo(provider="local", url=test_filename, duration=0)]
|
|
result = vd.preprocess_video(materials=materials, clip_duration=4)
|
|
self.assertTrue(len(result) > 0, "preprocess_video should return valid materials")
|
|
self.assertTrue(
|
|
os.path.isabs(result[0].url),
|
|
f"material url should be absolute path, got: {result[0].url}",
|
|
)
|
|
self.assertEqual(result[0].url, test_filepath)
|
|
finally:
|
|
if os.path.exists(test_filepath):
|
|
os.remove(test_filepath)
|
|
|
|
|
|
def test_local_source_requires_video_materials(self):
|
|
with self.assertRaises(SystemExit) as cm:
|
|
cli.parse_args(["--video-subject", "test", "--video-source", "local"])
|
|
self.assertNotEqual(cm.exception.code, 0)
|
|
|
|
def test_local_source_does_not_require_materials_before_material_stage(self):
|
|
for stop_at in ("script", "audio", "subtitle"):
|
|
with self.subTest(stop_at=stop_at):
|
|
args = cli.parse_args(
|
|
[
|
|
"--video-subject",
|
|
"test",
|
|
"--video-source",
|
|
"local",
|
|
"--stop-at",
|
|
stop_at,
|
|
]
|
|
)
|
|
self.assertEqual(args.stop_at, stop_at)
|
|
|
|
def test_local_source_stop_at_terms_rejected(self):
|
|
with self.assertRaises(SystemExit) as cm:
|
|
cli.parse_args([
|
|
"--video-subject", "test",
|
|
"--video-source", "local",
|
|
"--video-materials", "a.mp4",
|
|
"--stop-at", "terms",
|
|
])
|
|
self.assertNotEqual(cm.exception.code, 0)
|
|
|
|
def test_video_materials_rejected_for_online_source(self):
|
|
with self.assertRaises(SystemExit) as cm:
|
|
cli.parse_args(
|
|
[
|
|
"--video-subject",
|
|
"test",
|
|
"--video-source",
|
|
"pexels",
|
|
"--video-materials",
|
|
"a.mp4",
|
|
]
|
|
)
|
|
|
|
self.assertEqual(cm.exception.code, 2)
|
|
|
|
def test_custom_bgm_requires_file_and_file_implies_custom_mode(self):
|
|
with self.assertRaises(SystemExit):
|
|
cli.parse_args(
|
|
["--video-subject", "test", "--bgm-type", "custom"]
|
|
)
|
|
|
|
args = cli.parse_args(
|
|
["--video-subject", "test", "--bgm-file", "output001.mp3"]
|
|
)
|
|
self.assertEqual(args.bgm_type, "custom")
|
|
|
|
def test_invalid_aspect_and_non_finite_numbers_are_argument_errors(self):
|
|
invalid_argvs = [
|
|
["--video-subject", "test", "--video-aspect", "invalid"],
|
|
["--video-subject", "test", "--custom-position", "nan"],
|
|
["--video-subject", "test", "--voice-rate", "inf"],
|
|
]
|
|
for argv in invalid_argvs:
|
|
with self.subTest(argv=argv), self.assertRaises(SystemExit) as cm:
|
|
cli.parse_args(argv)
|
|
self.assertEqual(cm.exception.code, 2)
|
|
|
|
def test_custom_position_requires_custom_subtitle_position(self):
|
|
with self.assertRaises(SystemExit) as cm:
|
|
cli.parse_args(
|
|
["--video-subject", "test", "--custom-position", "50"]
|
|
)
|
|
|
|
self.assertEqual(cm.exception.code, 2)
|
|
|
|
def test_task_id_must_be_uuid(self):
|
|
task_id = str(uuid4())
|
|
args = cli.parse_args(
|
|
["--video-subject", "test", "--task-id", task_id]
|
|
)
|
|
self.assertEqual(args.task_id, task_id)
|
|
|
|
with self.assertRaises(SystemExit) as cm:
|
|
cli.parse_args(
|
|
["--video-subject", "test", "--task-id", "../../escape"]
|
|
)
|
|
self.assertEqual(cm.exception.code, 2)
|
|
|
|
def test_prepare_cli_files_accepts_relative_and_absolute_materials(self):
|
|
with (
|
|
tempfile.TemporaryDirectory() as source_dir,
|
|
tempfile.TemporaryDirectory() as managed_dir,
|
|
):
|
|
relative_file = Path(source_dir) / "relative.mp4"
|
|
absolute_file = Path(source_dir) / "absolute.jpg"
|
|
relative_file.write_bytes(b"relative-video")
|
|
absolute_file.write_bytes(b"absolute-image")
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(source_dir)
|
|
args = cli.parse_args(
|
|
[
|
|
"--video-subject",
|
|
"test",
|
|
"--video-source",
|
|
"local",
|
|
"--video-materials",
|
|
f"relative.mp4,{absolute_file}",
|
|
]
|
|
)
|
|
params = cli.build_video_params(args)
|
|
with patch("app.utils.utils.storage_dir", return_value=managed_dir):
|
|
cli.prepare_cli_files(params, stop_at="video")
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
prepared_paths = [Path(item.url) for item in params.video_materials]
|
|
self.assertTrue(all(path.parent == Path(managed_dir) for path in prepared_paths))
|
|
self.assertEqual(
|
|
{path.read_bytes() for path in prepared_paths},
|
|
{b"relative-video", b"absolute-image"},
|
|
)
|
|
|
|
def test_prepare_cli_files_rejects_missing_material_before_task_start(self):
|
|
args = cli.parse_args(
|
|
[
|
|
"--video-subject",
|
|
"test",
|
|
"--video-source",
|
|
"local",
|
|
"--video-materials",
|
|
"missing.mp4",
|
|
]
|
|
)
|
|
params = cli.build_video_params(args)
|
|
|
|
with tempfile.TemporaryDirectory() as managed_dir, patch(
|
|
"app.utils.utils.storage_dir", return_value=managed_dir
|
|
):
|
|
with self.assertRaisesRegex(ValueError, "does not exist"):
|
|
cli.prepare_cli_files(params, stop_at="video")
|
|
|
|
def test_run_cli_rejects_missing_material_before_starting_task(self):
|
|
with patch("app.services.task.start") as start:
|
|
code = cli.run_cli(
|
|
[
|
|
"--video-subject",
|
|
"test",
|
|
"--video-source",
|
|
"local",
|
|
"--video-materials",
|
|
"missing.mp4",
|
|
]
|
|
)
|
|
|
|
self.assertEqual(code, 2)
|
|
start.assert_not_called()
|
|
|
|
def test_prepare_cli_files_resolves_relative_custom_audio(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
audio_file = Path(temp_dir) / "voiceover.mp3"
|
|
audio_file.write_bytes(b"audio")
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(temp_dir)
|
|
args = cli.parse_args(
|
|
[
|
|
"--video-subject",
|
|
"test",
|
|
"--custom-audio-file",
|
|
"voiceover.mp3",
|
|
"--stop-at",
|
|
"audio",
|
|
]
|
|
)
|
|
params = cli.build_video_params(args)
|
|
cli.prepare_cli_files(params, stop_at="audio")
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
self.assertEqual(params.custom_audio_file, str(audio_file.resolve()))
|
|
|
|
def test_help_documents_defaults_paths_stages_and_exit_codes(self):
|
|
output = io.StringIO()
|
|
with redirect_stdout(output), self.assertRaises(SystemExit) as cm:
|
|
cli.parse_args(["--help"])
|
|
|
|
self.assertEqual(cm.exception.code, 0)
|
|
help_text = output.getvalue()
|
|
self.assertIn("zh-CN-XiaoxiaoNeural-Female", help_text)
|
|
self.assertIn("current working directory", help_text)
|
|
self.assertIn("Pipeline stages:", help_text)
|
|
self.assertIn("exit with 2", help_text)
|
|
|
|
def test_help_does_not_initialize_application_or_write_logs(self):
|
|
"""帮助命令应独立于业务配置加载,便于用户查看和脚本采集。"""
|
|
project_root = Path(__file__).parent.parent.parent
|
|
result = subprocess.run(
|
|
[sys.executable, str(project_root / "cli.py"), "--help"],
|
|
cwd=project_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
self.assertEqual(result.returncode, 0)
|
|
self.assertIn("Generate MoneyPrinterTurbo videos", result.stdout)
|
|
self.assertEqual(result.stderr, "")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|