初始化提交

This commit is contained in:
lvyulong
2026-06-12 14:59:20 +08:00
parent c1d00901a8
commit 00718f47f3
85 changed files with 15799 additions and 0 deletions

40
test/README.md Normal file
View File

@@ -0,0 +1,40 @@
# MoneyPrinterTurbo Test Directory
This directory contains unit tests for the **MoneyPrinterTurbo** project.
## Directory Structure
- `services/`: Tests for components in the `app/services` directory
- `test_video.py`: Tests for the video service
- `test_task.py`: Tests for the task service
- `test_voice.py`: Tests for the voice service
## Running Tests
You can run the tests using Pythons built-in `unittest` framework:
```bash
# Run all tests
python -m unittest discover -s test
# Run a specific test file
python -m unittest test/services/test_video.py
# Run a specific test class
python -m unittest test.services.test_video.TestVideoService
# Run a specific test method
python -m unittest test.services.test_video.TestVideoService.test_preprocess_video
````
## Adding New Tests
To add tests for other components, follow these guidelines:
1. Create test files prefixed with `test_` in the appropriate subdirectory
2. Use `unittest.TestCase` as the base class for your test classes
3. Name test methods with the `test_` prefix
## Test Resources
Place any resource files required for testing in the `test/resources` directory.

1
test/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Unit test package for test

BIN
test/resources/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
test/resources/1.png.mp4 Normal file

Binary file not shown.

BIN
test/resources/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

BIN
test/resources/2.png.mp4 Normal file

Binary file not shown.

BIN
test/resources/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

BIN
test/resources/3.png.mp4 Normal file

Binary file not shown.

BIN
test/resources/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
test/resources/5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

BIN
test/resources/6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

BIN
test/resources/7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

BIN
test/resources/8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
test/resources/9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -0,0 +1 @@
# Unit test package for services

505
test/services/test_llm.py Normal file
View File

@@ -0,0 +1,505 @@
import os
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest.mock import patch
from pydantic import ValidationError
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from app.config import config
from app.models.schema import VideoScriptRequest
from app.services import llm
class TestScriptPromptOptions(unittest.TestCase):
def test_build_script_prompt_appends_advanced_requirements(self):
"""
高级文案要求只作为附加约束,不替换默认系统提示词。
这样普通用户不配置时仍然走稳定默认规则,高级用户也能细化风格。
"""
prompt = llm.build_script_prompt(
video_subject="咖啡",
language="zh-CN",
paragraph_number=3,
video_script_prompt="语气轻松,面向程序员",
)
self.assertIn("# Role: Video Script Generator", prompt)
self.assertIn("- video subject: 咖啡", prompt)
self.assertIn("- number of paragraphs: 3", prompt)
self.assertIn("- language: zh-CN", prompt)
self.assertIn("# Additional User Requirements:", prompt)
self.assertIn("语气轻松,面向程序员", prompt)
def test_custom_system_prompt_keeps_runtime_context(self):
"""
自定义 system prompt 会替换默认脚本规则,但视频主题、语言、段落数
仍由服务层统一追加,避免高级用户漏写必要上下文。
"""
prompt = llm.build_script_prompt(
video_subject="露营",
language="en",
paragraph_number=2,
custom_system_prompt="Only write cinematic narration.",
)
self.assertNotIn("# Role: Video Script Generator", prompt)
self.assertIn("Only write cinematic narration.", prompt)
self.assertIn("- video subject: 露营", prompt)
self.assertIn("- number of paragraphs: 2", prompt)
self.assertIn("- language: en", prompt)
def test_generate_script_sends_custom_prompt_to_llm(self):
captured = {}
def fake_generate_response(prompt):
captured["prompt"] = prompt
return "第一段。\n\n第二段。"
with patch.object(llm, "_generate_response", side_effect=fake_generate_response):
result = llm.generate_script(
video_subject="咖啡",
language="zh-CN",
paragraph_number=2,
video_script_prompt="开头更有悬念",
)
self.assertEqual(result, "第一段。\n\n第二段。")
self.assertIn("- number of paragraphs: 2", captured["prompt"])
self.assertIn("开头更有悬念", captured["prompt"])
def test_video_script_request_rejects_invalid_advanced_options(self):
"""
API 请求模型需要限制高级 prompt 参数,避免外部调用绕过 WebUI
传入异常段落数或超长提示词,导致模型成本和结果不可控。
"""
with self.assertRaises(ValidationError):
VideoScriptRequest(video_subject="咖啡", paragraph_number=0)
with self.assertRaises(ValidationError):
VideoScriptRequest(
video_subject="咖啡",
video_script_prompt="x" * (llm.MAX_SCRIPT_PROMPT_LENGTH + 1),
)
class TestLiteLLMProvider(unittest.TestCase):
def setUp(self):
self.original_app_config = dict(config.app)
def tearDown(self):
config.app.clear()
config.app.update(self.original_app_config)
def _use_litellm_provider(self, model_name="openai/gpt-4o-mini"):
config.app["llm_provider"] = "litellm"
config.app["litellm_model_name"] = model_name
def test_litellm_provider_returns_normalized_text(self):
"""
验证 LiteLLM provider 的主路径不依赖真实网络和私有 API key。
这里用 fake module 注入 `sys.modules`,直接覆盖动态 import 的
`litellm.completion()`,确保测试稳定覆盖 `_generate_response()` 里的
litellm 分支。
"""
self._use_litellm_provider()
fake_litellm = types.SimpleNamespace()
def _completion(**kwargs):
self.assertEqual(kwargs["model"], "openai/gpt-4o-mini")
self.assertEqual(
kwargs["messages"], [{"role": "user", "content": "Say hello"}]
)
self.assertTrue(kwargs["drop_params"])
message = types.SimpleNamespace(content="hello\nworld")
choice = types.SimpleNamespace(message=message)
return types.SimpleNamespace(choices=[choice])
fake_litellm.completion = _completion
with patch.dict(sys.modules, {"litellm": fake_litellm}):
result = llm._generate_response("Say hello")
self.assertEqual(result, "helloworld")
def test_litellm_provider_requires_model_name(self):
self._use_litellm_provider(model_name="")
result = llm._generate_response("test")
self.assertIn("Error:", result)
self.assertIn("model_name is not set", result)
def test_litellm_provider_handles_empty_response(self):
self._use_litellm_provider()
fake_litellm = types.SimpleNamespace(
completion=lambda **kwargs: types.SimpleNamespace(choices=[])
)
with patch.dict(sys.modules, {"litellm": fake_litellm}):
result = llm._generate_response("test")
self.assertIn("Error:", result)
self.assertIn("returned empty response", result)
def test_litellm_provider_handles_empty_message(self):
"""
某些 OpenAI-compatible 网关在内容过滤或安全拦截时会返回
HTTP 200但 `choices[0].message` 为 None。这里必须返回
可诊断的错误,而不是抛出 AttributeError。
"""
self._use_litellm_provider()
fake_litellm = types.SimpleNamespace(
completion=lambda **kwargs: types.SimpleNamespace(
choices=[types.SimpleNamespace(message=None)]
)
)
with patch.dict(sys.modules, {"litellm": fake_litellm}):
result = llm._generate_response("test")
self.assertIn("Error:", result)
self.assertIn("returned empty message", result)
def test_openai_provider_still_uses_existing_path(self):
config.app["llm_provider"] = "openai"
config.app["openai_api_key"] = ""
config.app["openai_base_url"] = "https://api.openai.com/v1"
config.app["openai_model_name"] = "gpt-4o-mini"
result = llm._generate_response("test")
self.assertIn("Error:", result)
self.assertIn("api_key is not set", result)
self.assertNotIn("litellm", result.lower())
def test_grok_provider_still_uses_existing_path(self):
config.app["llm_provider"] = "grok"
config.app["grok_api_key"] = ""
config.app["grok_base_url"] = "https://api.x.ai/v1"
config.app["grok_model_name"] = "grok-4.3"
result = llm._generate_response("test")
self.assertIn("Error:", result)
self.assertIn("api_key is not set", result)
self.assertNotIn("litellm", result.lower())
def _use_ollama_provider(self, base_url=""):
config.app["llm_provider"] = "ollama"
config.app["ollama_api_key"] = ""
config.app["ollama_base_url"] = base_url
config.app["ollama_model_name"] = "llama3"
def _assert_ollama_base_url(self, expected_base_url: str):
class FakeCompletions:
def create(self, **kwargs):
self.kwargs = kwargs
message = types.SimpleNamespace(content="hello\nollama")
choice = types.SimpleNamespace(message=message)
return types.SimpleNamespace(choices=[choice])
fake_completions = FakeCompletions()
fake_client = types.SimpleNamespace(
chat=types.SimpleNamespace(completions=fake_completions)
)
with (
patch.object(llm, "OpenAI", return_value=fake_client) as openai_client,
patch.object(llm, "ChatCompletion", types.SimpleNamespace),
):
result = llm._generate_response("Say hello")
openai_client.assert_called_once_with(
api_key="ollama",
base_url=expected_base_url,
)
self.assertEqual(
fake_completions.kwargs,
{
"model": "llama3",
"messages": [{"role": "user", "content": "Say hello"}],
},
)
self.assertEqual(result, "helloollama")
def test_ollama_default_base_url_uses_localhost_outside_container(self):
"""
普通本机运行时Ollama 默认仍然使用 localhost避免影响已有用户。
"""
self._use_ollama_provider()
with patch.object(config, "is_running_in_container", return_value=False):
self._assert_ollama_base_url("http://localhost:11434/v1")
def test_ollama_default_base_url_uses_host_gateway_inside_container(self):
"""
容器内运行时localhost 指向容器自身;默认改为 host.docker.internal
方便 Docker Desktop 用户访问宿主机上的 Ollama。
"""
self._use_ollama_provider()
with (
patch.object(config, "is_running_in_container", return_value=True),
patch.object(config, "_can_resolve_hostname", return_value=True),
):
self._assert_ollama_base_url("http://host.docker.internal:11434/v1")
def test_ollama_default_base_url_falls_back_to_container_gateway(self):
"""
原生 Linux Docker 里不一定能解析 host.docker.internal。此时使用容器
默认网关作为兜底地址,比直接返回不可解析的 hostname 更稳。
"""
self._use_ollama_provider()
with (
patch.object(config, "is_running_in_container", return_value=True),
patch.object(config, "_can_resolve_hostname", return_value=False),
patch.object(config, "get_container_default_gateway_ip", return_value="172.17.0.1"),
):
self._assert_ollama_base_url("http://172.17.0.1:11434/v1")
def test_ollama_explicit_base_url_takes_precedence(self):
"""
用户手动配置的 ollama_base_url 优先级最高,不受容器检测影响。
"""
self._use_ollama_provider(base_url="http://ollama:11434/v1")
with patch.object(config, "is_running_in_container", return_value=True):
self._assert_ollama_base_url("http://ollama:11434/v1")
def test_mimo_provider_uses_openai_compatible_client(self):
"""
MiMo 官方接口兼容 OpenAI Chat Completions 协议。这里用 fake OpenAI
client 验证 provider 会使用 MiMo 独立配置和默认 base_url不依赖
真实网络或私有 API Key。
"""
config.app["llm_provider"] = "mimo"
config.app["mimo_api_key"] = "mimo-key"
config.app["mimo_base_url"] = ""
config.app["mimo_model_name"] = ""
class FakeCompletions:
def create(self, **kwargs):
self.kwargs = kwargs
message = types.SimpleNamespace(content="hello\nmimo")
choice = types.SimpleNamespace(message=message)
return types.SimpleNamespace(choices=[choice])
fake_completions = FakeCompletions()
fake_client = types.SimpleNamespace(
chat=types.SimpleNamespace(completions=fake_completions)
)
with (
patch.object(llm, "OpenAI", return_value=fake_client) as openai_client,
patch.object(llm, "ChatCompletion", types.SimpleNamespace),
):
result = llm._generate_response("Say hello")
openai_client.assert_called_once_with(
api_key="mimo-key",
base_url="https://api.xiaomimimo.com/v1",
)
self.assertEqual(
fake_completions.kwargs,
{
"model": "mimo-v2.5-pro",
"messages": [{"role": "user", "content": "Say hello"}],
},
)
self.assertEqual(result, "hellomimo")
def test_azure_provider_uses_azure_client_directly(self):
"""
Azure OpenAI 的鉴权、endpoint 和 api-version 都由 AzureOpenAI 客户端处理。
这个测试覆盖 issue #892azure 分支必须直接调用 AzureOpenAI 创建的客户端,
不能继续落入普通 OpenAI-compatible 分支,否则会丢失 Azure 专用请求配置。
"""
config.app["llm_provider"] = "azure"
config.app["azure_api_key"] = "azure-key"
config.app["azure_base_url"] = "https://example.openai.azure.com"
config.app["azure_model_name"] = "gpt-4o-mini"
config.app["azure_api_version"] = "2024-02-15-preview"
class FakeCompletions:
def create(self, **kwargs):
self.kwargs = kwargs
message = types.SimpleNamespace(content="hello\nazure")
choice = types.SimpleNamespace(message=message)
return types.SimpleNamespace(choices=[choice])
fake_completions = FakeCompletions()
fake_client = types.SimpleNamespace(
chat=types.SimpleNamespace(completions=fake_completions)
)
with (
patch.object(llm, "AzureOpenAI", return_value=fake_client) as azure_client,
patch.object(llm, "OpenAI") as openai_client,
patch.object(llm, "ChatCompletion", types.SimpleNamespace),
):
result = llm._generate_response("Say hello")
azure_client.assert_called_once_with(
api_key="azure-key",
api_version="2024-02-15-preview",
azure_endpoint="https://example.openai.azure.com",
)
openai_client.assert_not_called()
self.assertEqual(
fake_completions.kwargs,
{
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "Say hello"}],
},
)
self.assertEqual(result, "helloazure")
def test_g4f_provider_requires_explicit_opt_in(self):
"""
g4f 存在供应链和稳定性风险,不能因为用户把 provider 写成 g4f
就默认加载第三方包并访问逆向接口,必须显式启用。
"""
config.app["llm_provider"] = "g4f"
config.app["enable_g4f"] = False
result = llm._generate_response("test")
self.assertIn("Error:", result)
self.assertIn("g4f provider is disabled", result)
def test_g4f_provider_uses_lazy_import_after_opt_in(self):
config.app["llm_provider"] = "g4f"
config.app["enable_g4f"] = True
config.app["g4f_model_name"] = "gpt-3.5-turbo"
fake_g4f = types.SimpleNamespace()
fake_g4f.ChatCompletion = types.SimpleNamespace(
create=lambda **kwargs: "hello from g4f"
)
with patch.dict(sys.modules, {"g4f": fake_g4f}):
result = llm._generate_response("test")
self.assertEqual(result, "hello from g4f")
def test_g4f_provider_reports_missing_optional_dependency(self):
config.app["llm_provider"] = "g4f"
config.app["enable_g4f"] = True
config.app["g4f_model_name"] = "gpt-3.5-turbo"
with patch.dict(sys.modules, {"g4f": None}):
result = llm._generate_response("test")
self.assertIn("Error:", result)
self.assertIn("g4f package is not installed by default", result)
class TestRuntimeEnvironmentDetection(unittest.TestCase):
def test_container_detection_ignores_plain_linux_cgroup_file(self):
"""
普通 Linux 也有 /proc/1/cgroup不能因为文件存在就判定为容器。
"""
with tempfile.TemporaryDirectory() as tmp_dir:
cgroup_path = Path(tmp_dir) / "cgroup"
cgroup_path.write_text("0::/init.scope\n", encoding="utf-8")
self.assertFalse(
config.is_running_in_container(
dockerenv_path=str(Path(tmp_dir) / "missing-dockerenv"),
containerenv_path=str(Path(tmp_dir) / "missing-containerenv"),
cgroup_path=str(cgroup_path),
)
)
def test_container_detection_accepts_dockerenv_marker(self):
with tempfile.TemporaryDirectory() as tmp_dir:
dockerenv_path = Path(tmp_dir) / ".dockerenv"
dockerenv_path.write_text("", encoding="utf-8")
self.assertTrue(
config.is_running_in_container(
dockerenv_path=str(dockerenv_path),
containerenv_path=str(Path(tmp_dir) / "missing-containerenv"),
cgroup_path=str(Path(tmp_dir) / "missing-cgroup"),
)
)
def test_container_detection_accepts_cgroup_container_marker(self):
with tempfile.TemporaryDirectory() as tmp_dir:
cgroup_path = Path(tmp_dir) / "cgroup"
cgroup_path.write_text(
"0::/system.slice/docker-abcdef.scope\n",
encoding="utf-8",
)
self.assertTrue(
config.is_running_in_container(
dockerenv_path=str(Path(tmp_dir) / "missing-dockerenv"),
containerenv_path=str(Path(tmp_dir) / "missing-containerenv"),
cgroup_path=str(cgroup_path),
)
)
def test_container_gateway_ip_decodes_default_route(self):
with tempfile.TemporaryDirectory() as tmp_dir:
route_path = Path(tmp_dir) / "route"
route_path.write_text(
"Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n"
"eth0\t00000000\t010011AC\t0003\t0\t0\t0\t00000000\t0\t0\t0\n",
encoding="utf-8",
)
self.assertEqual(
config.get_container_default_gateway_ip(str(route_path)),
"172.17.0.1",
)
def test_container_gateway_ip_ignores_missing_default_route(self):
with tempfile.TemporaryDirectory() as tmp_dir:
route_path = Path(tmp_dir) / "route"
route_path.write_text(
"Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n"
"eth0\t0011AC0A\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0\n",
encoding="utf-8",
)
self.assertEqual(config.get_container_default_gateway_ip(str(route_path)), "")
FOUNDRY_KEY = os.environ.get("ANTHROPIC_FOUNDRY_API_KEY", "")
FOUNDRY_BASE = "https://amanrai-test-resource.services.ai.azure.com/anthropic"
FOUNDRY_MODEL = "azure_ai/claude-sonnet-4-6"
@unittest.skipUnless(FOUNDRY_KEY, "ANTHROPIC_FOUNDRY_API_KEY not set")
class TestLiteLLMLiveIntegration(unittest.TestCase):
def setUp(self):
self.original_app_config = dict(config.app)
config.app["llm_provider"] = "litellm"
config.app["litellm_model_name"] = FOUNDRY_MODEL
os.environ["AZURE_AI_API_KEY"] = FOUNDRY_KEY
os.environ["AZURE_AI_API_BASE"] = FOUNDRY_BASE
def tearDown(self):
config.app.clear()
config.app.update(self.original_app_config)
def test_live_litellm_completion(self):
result = llm._generate_response("What is 2+2? Reply with just the number.")
self.assertNotIn("Error:", result)
self.assertIn("4", result)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,132 @@
import os
import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from app.config import config
from app.services import material
class TestMaterialTlsVerification(unittest.TestCase):
def setUp(self):
self.original_app_config = dict(config.app)
self.original_proxy_config = dict(config.proxy)
def tearDown(self):
config.app.clear()
config.app.update(self.original_app_config)
config.proxy.clear()
config.proxy.update(self.original_proxy_config)
def test_search_pexels_uses_tls_verification_by_default(self):
"""
默认路径必须开启 TLS 校验,避免素材 API key 和返回的素材 URL
在公共网络或不可信代理环境中被中间人攻击截获或篡改。
"""
config.app["pexels_api_keys"] = ["pexels-key"]
config.app.pop("tls_verify", None)
config.proxy.clear()
fake_response = SimpleNamespace(
json=lambda: {
"videos": [
{
"duration": 8,
"video_files": [
{
"width": 1080,
"height": 1920,
"link": "https://example.com/video.mp4",
}
],
}
]
}
)
with patch("app.services.material.requests.get", return_value=fake_response) as get:
results = material.search_videos_pexels("cat", minimum_duration=1)
self.assertEqual(len(results), 1)
self.assertTrue(get.call_args.kwargs["verify"])
def test_search_pixabay_allows_explicit_tls_disable_for_proxy(self):
"""
少数企业代理会使用自签证书。该场景必须显式配置关闭 TLS 校验,
不能再由代码硬编码默认关闭。
"""
config.app["pixabay_api_keys"] = ["pixabay-key"]
config.app["tls_verify"] = False
config.proxy.clear()
fake_response = SimpleNamespace(
json=lambda: {
"hits": [
{
"duration": 8,
"videos": {
"large": {
"width": 1920,
"url": "https://example.com/video.mp4",
}
},
}
]
}
)
with patch("app.services.material.requests.get", return_value=fake_response) as get:
results = material.search_videos_pixabay("cat", minimum_duration=1)
self.assertEqual(len(results), 1)
self.assertFalse(get.call_args.kwargs["verify"])
def test_save_video_uses_tls_verification_by_default(self):
config.app.pop("tls_verify", None)
config.proxy.clear()
fake_response = SimpleNamespace(content=b"fake-video")
class FakeVideoFileClip:
duration = 1
fps = 24
def __init__(self, path):
self.path = path
def close(self):
return None
with tempfile.TemporaryDirectory() as temp_dir:
with patch(
"app.services.material.requests.get", return_value=fake_response
) as get, patch("app.services.material.VideoFileClip", FakeVideoFileClip):
video_path = material.save_video(
"https://example.com/video.mp4?token=abc", save_dir=temp_dir
)
self.assertTrue(os.path.exists(video_path))
self.assertTrue(get.call_args.kwargs["verify"])
def test_download_videos_accepts_plain_string_concat_mode(self):
"""
download_videos 可能被服务层或测试直接传入字符串模式,而不是
VideoConcatMode 枚举。这里用空搜索词避免真实网络请求,只验证
字符串 "random" 不会再因为访问 `.value` 抛 AttributeError。
"""
result = material.download_videos(
task_id="string-concat-mode",
search_terms=[],
video_contact_mode="random",
)
self.assertEqual(result, [])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,74 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from app.services.state import RedisState
class _FakeRedis:
def __init__(self, batches):
self.batches = batches
self.data = {}
for key in [key for batch in batches for key in batch]:
index = int(key.decode("utf-8").split(":")[-1])
self.data[key] = {
b"task_id": key,
b"state": b"1",
b"progress": str(index).encode("utf-8"),
}
def scan(self, cursor, count):
batch_index = int(cursor)
next_cursor = batch_index + 1
if next_cursor >= len(self.batches):
next_cursor = 0
return next_cursor, self.batches[batch_index]
def hgetall(self, key):
return self.data[key]
class TestRedisState(unittest.TestCase):
def _build_state(self, batch_sizes):
keys = [f"task:{i}".encode("utf-8") for i in range(sum(batch_sizes))]
batches = []
offset = 0
for batch_size in batch_sizes:
batches.append(keys[offset : offset + batch_size])
offset += batch_size
state = RedisState.__new__(RedisState)
state._redis = _FakeRedis(batches)
return state
def test_get_all_tasks_paginates_across_scan_batches(self):
"""
Redis SCAN 分批返回 key 时,分页切片必须按当前批次起始位置计算。
这个用例复现 PR #890 描述的 18 条任务、page_size=10 场景:
第一批 10 条,第二批 8 条。旧逻辑第一页会返回空列表,第二页
只返回 2 条;修复后第一页返回 10 条,第二页返回剩余 8 条。
"""
state = self._build_state([10, 8])
first_page, first_total = state.get_all_tasks(page=1, page_size=10)
second_page, second_total = state.get_all_tasks(page=2, page_size=10)
self.assertEqual(first_total, 18)
self.assertEqual(second_total, 18)
self.assertEqual(len(first_page), 10)
self.assertEqual(len(second_page), 8)
self.assertEqual(
[task["task_id"] for task in first_page],
[f"task:{i}" for i in range(10)],
)
self.assertEqual(
[task["task_id"] for task in second_page],
[f"task:{i}" for i in range(10, 18)],
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,93 @@
import unittest
import os
import sys
from pathlib import Path
from unittest.mock import patch
# add project root to python path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from app.services import task as tm
from app.models.schema import MaterialInfo, VideoParams
resources_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "resources")
class TestTaskService(unittest.TestCase):
def setUp(self):
pass
def tearDown(self):
pass
def test_generate_script_forwards_advanced_prompt_options(self):
"""
任务生成入口和 WebUI/API 共用 VideoParams。这里验证自动生成文案时
高级提示词参数会继续传到 LLM 服务层,避免只在 /scripts 接口生效。
"""
params = VideoParams(
video_subject="咖啡",
video_script="",
video_language="zh-CN",
paragraph_number=2,
video_script_prompt="语气轻松",
custom_system_prompt="Only write short narration.",
)
with patch.object(tm.llm, "generate_script", return_value="生成的文案") as generate:
result = tm.generate_script("task-id", params)
self.assertEqual(result, "生成的文案")
generate.assert_called_once_with(
video_subject="咖啡",
language="zh-CN",
paragraph_number=2,
video_script_prompt="语气轻松",
custom_system_prompt="Only write short narration.",
)
def test_task_local_materials(self):
task_id = "00000000-0000-0000-0000-000000000000"
video_materials=[]
for i in range(1, 4):
video_materials.append(MaterialInfo(
provider="local",
url=os.path.join(resources_dir, f"{i}.png"),
duration=0
))
params = VideoParams(
video_subject="金钱的作用",
video_script="金钱不仅是交换媒介,更是社会资源的分配工具。它能满足基本生存需求,如食物和住房,也能提供教育、医疗等提升生活品质的机会。拥有足够的金钱意味着更多选择权,比如职业自由或创业可能。但金钱的作用也有边界,它无法直接购买幸福、健康或真诚的人际关系。过度追逐财富可能导致价值观扭曲,忽视精神层面的需求。理想的状态是理性看待金钱,将其作为实现目标的工具而非终极目的。",
video_terms="money importance, wealth and society, financial freedom, money and happiness, role of money",
video_aspect="9:16",
video_concat_mode="random",
video_transition_mode="None",
video_clip_duration=3,
video_count=1,
video_source="local",
video_materials=video_materials,
video_language="",
voice_name="zh-CN-XiaoxiaoNeural-Female",
voice_volume=1.0,
voice_rate=1.0,
bgm_type="random",
bgm_file="",
bgm_volume=0.2,
subtitle_enabled=True,
subtitle_position="bottom",
custom_position=70.0,
font_name="MicrosoftYaHeiBold.ttc",
text_fore_color="#FFFFFF",
text_background_color=True,
font_size=60,
stroke_color="#000000",
stroke_width=1.5,
n_threads=2,
paragraph_number=1
)
result = tm.start(task_id=task_id, params=params)
print(result)
if __name__ == "__main__":
unittest.main()

315
test/services/test_video.py Normal file
View File

@@ -0,0 +1,315 @@
import unittest
import os
import shutil
import sys
import tempfile
import types
from contextlib import redirect_stdout
from io import StringIO
from pathlib import Path
from unittest.mock import patch
from moviepy import (
VideoFileClip,
)
# add project root to python path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from app.config import config
from app.controllers.manager.base_manager import TaskQueueFullError
from app.controllers.manager.memory_manager import InMemoryTaskManager
from app.controllers.v1 import video as video_controller
from app.models import const
from app.models.schema import MaterialInfo
from app.services import state as sm
from app.services import video as vd
from app.utils import utils
resources_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "resources")
class _FakeRequest:
def __init__(self):
self.headers = {"x-task-id": "test-request"}
class TestSecurityControls(unittest.TestCase):
def setUp(self):
self.original_app_config = dict(config.app)
def tearDown(self):
config.app.clear()
config.app.update(self.original_app_config)
def test_task_query_returns_relative_task_url_without_mutating_state(self):
"""
endpoint 未显式配置时,任务查询接口不能使用 Host 派生绝对 URL
也不能把展示 URL 回写到任务状态里,否则不同 Host 查询会污染结果。
"""
task_id = "security-task-url"
task_dir = utils.task_dir(task_id)
video_path = os.path.join(task_dir, "final-1.mp4")
Path(video_path).write_bytes(b"fake-video")
config.app["endpoint"] = ""
try:
sm.state.update_task(
task_id,
state=const.TASK_STATE_COMPLETE,
videos=[video_path],
combined_videos=[video_path],
)
response = video_controller.get_task(_FakeRequest(), task_id=task_id)
self.assertEqual(response["data"]["videos"], [f"/tasks/{task_id}/final-1.mp4"])
self.assertEqual(sm.state.get_task(task_id)["videos"], [video_path])
finally:
sm.state.delete_task(task_id)
shutil.rmtree(task_dir, ignore_errors=True)
def test_in_memory_task_manager_rejects_when_queue_is_full(self):
"""
并发数用尽后,等待队列必须有硬上限。这里用 max_concurrent_tasks=0
强制任务进入队列,验证超过 max_queued_tasks 时会拒绝继续入队。
"""
manager = InMemoryTaskManager(max_concurrent_tasks=0, max_queued_tasks=1)
manager.add_task(lambda: None)
with self.assertRaises(TaskQueueFullError):
manager.add_task(lambda: None)
class TestVideoService(unittest.TestCase):
def setUp(self):
self.test_img_path = os.path.join(resources_dir, "1.png")
def tearDown(self):
pass
def test_preprocess_video(self):
if not os.path.exists(self.test_img_path):
self.fail(f"test image not found: {self.test_img_path}")
local_videos_dir = utils.storage_dir("local_videos", create=True)
safe_img_path = os.path.join(local_videos_dir, "test-preprocess-1.png")
shutil.copy2(self.test_img_path, safe_img_path)
# test preprocess_video function
m = MaterialInfo()
m.url = os.path.basename(safe_img_path)
m.provider = "local"
print(m)
try:
materials = vd.preprocess_video([m], clip_duration=4)
print(materials)
# verify result
self.assertIsNotNone(materials)
self.assertEqual(len(materials), 1)
self.assertTrue(materials[0].url.endswith(".mp4"))
# moviepy get video info
clip = VideoFileClip(materials[0].url)
try:
print(clip)
finally:
clip.close()
# clean generated test video file
if os.path.exists(materials[0].url):
os.remove(materials[0].url)
finally:
if os.path.exists(safe_img_path):
os.remove(safe_img_path)
def test_preprocess_video_rejects_material_outside_local_videos(self):
"""
local 素材路径来自 API 参数,不能允许任意绝对路径进入 MoviePy。
这里验证非 local_videos 白名单目录内的路径会被跳过,避免任意文件读取。
"""
m = MaterialInfo(provider="local", url=self.test_img_path)
materials = vd.preprocess_video([m], clip_duration=4)
self.assertEqual(materials, [])
def test_get_bgm_file_accepts_song_directory_filename(self):
"""
BGM 列表接口现在只暴露文件名;生成视频时应能把文件名安全解析回
resource/songs 白名单目录,保持正常使用路径可用。
"""
song_dir = utils.song_dir()
bgm_path = os.path.join(song_dir, "test-safe-bgm.mp3")
Path(bgm_path).write_bytes(b"fake-mp3")
try:
self.assertEqual(vd.get_bgm_file(bgm_file="test-safe-bgm.mp3"), bgm_path)
finally:
if os.path.exists(bgm_path):
os.remove(bgm_path)
def test_get_bgm_file_accepts_project_relative_song_path(self):
"""
用户在 WebUI 中可能直接填写 ./resource/songs/xxx.mp3。该路径虽然是
项目根目录相对路径,但实际文件仍在 resource/songs 白名单目录内,
应该被接受,避免自定义背景音乐被误判为不存在。
"""
song_dir = utils.song_dir()
bgm_path = os.path.join(song_dir, "test-relative-bgm.mp3")
Path(bgm_path).write_bytes(b"fake-mp3")
try:
self.assertEqual(
vd.get_bgm_file(bgm_file="./resource/songs/test-relative-bgm.mp3"),
bgm_path,
)
finally:
if os.path.exists(bgm_path):
os.remove(bgm_path)
def test_get_bgm_file_rejects_path_outside_song_directory(self):
"""
用户传入的 bgm_file 不能直接作为本地路径打开,否则可能读取系统文件。
即使外部文件存在,也必须因为不在 songs 目录内被拒绝。
"""
with tempfile.NamedTemporaryFile(suffix=".mp3") as temp_bgm:
self.assertEqual(vd.get_bgm_file(bgm_file=temp_bgm.name), "")
def test_get_ffmpeg_binary_uses_configured_env_path(self):
"""配置中显式指定 ffmpeg 时,应优先使用该路径。"""
with patch.dict(os.environ, {"IMAGEIO_FFMPEG_EXE": "/tmp/custom-ffmpeg"}, clear=True):
self.assertEqual(vd.get_ffmpeg_binary(), "/tmp/custom-ffmpeg")
def test_get_ffmpeg_binary_falls_back_to_imageio_ffmpeg(self):
"""
Windows 便携包里系统 PATH 可能没有 ffmpeg但 moviepy 依赖的
imageio-ffmpeg 通常会提供可执行文件。这里验证该兜底路径可用。
"""
fake_imageio_ffmpeg = types.SimpleNamespace(
get_ffmpeg_exe=lambda: "/tmp/bundled-ffmpeg"
)
with patch.dict(os.environ, {}, clear=True), patch.object(
vd.shutil, "which", return_value=None
), patch.dict(sys.modules, {"imageio_ffmpeg": fake_imageio_ffmpeg}):
self.assertEqual(vd.get_ffmpeg_binary(), "/tmp/bundled-ffmpeg")
def test_open_video_clip_quietly_suppresses_moviepy_stdout(self):
"""
MoviePy 2.1.x 的 FFMPEG_VideoReader 会直接向 stdout 打印 metadata
和 ffmpeg 命令。项目服务层应屏蔽这类依赖库噪声,避免用户把
`audio_found: False` 误判为最终视频没有音频。
"""
video_path = os.path.join(resources_dir, "1.png.mp4")
if not os.path.exists(video_path):
self.fail(f"test video not found: {video_path}")
stdout = StringIO()
with redirect_stdout(stdout):
clip = vd._open_video_clip_quietly(video_path)
try:
self.assertEqual(stdout.getvalue(), "")
self.assertIsNone(clip.audio)
self.assertGreater(clip.duration, 0)
finally:
vd.close_clip(clip)
def test_combine_videos_closes_audio_clip_when_duration_read_fails(self):
"""
`combine_videos()` 只需要读取旁白音频时长。即使读取 duration
时发生异常,也必须关闭 AudioFileClip避免文件句柄泄漏。
"""
class _FakeAudioReader:
def __init__(self):
self.closed = False
def close(self):
self.closed = True
class _BrokenAudioClip:
def __init__(self):
self.reader = _FakeAudioReader()
@property
def duration(self):
raise RuntimeError("failed to read duration")
fake_audio_clip = _BrokenAudioClip()
with patch.object(vd, "AudioFileClip", return_value=fake_audio_clip):
with self.assertRaises(RuntimeError):
vd.combine_videos(
combined_video_path="/tmp/unused-combined.mp4",
video_paths=[],
audio_file="/tmp/unused-audio.mp3",
)
self.assertTrue(fake_audio_clip.reader.closed)
def test_combine_videos_handles_none_transition_mode(self):
"""
Ensure `combine_videos` safely handles
`video_transition_mode=None`.
"""
class _FakeAudioClip:
@property
def duration(self):
return 10.0
def close(self):
pass
with tempfile.TemporaryDirectory() as temp_dir:
combined_video_path = os.path.join(temp_dir, "combined.mp4")
audio_file = os.path.join(temp_dir, "audio.mp3")
with patch.object(vd, "AudioFileClip", return_value=_FakeAudioClip()):
# Use empty video_paths to avoid heavy video processing while
# still exercising transition mode normalization logic.
result = vd.combine_videos(
combined_video_path=combined_video_path,
video_paths=[],
audio_file=audio_file,
video_transition_mode=None,
)
self.assertEqual(result, combined_video_path)
def test_wrap_text(self):
"""test text wrapping function"""
try:
font_path = os.path.join(utils.font_dir(), "STHeitiMedium.ttc")
if not os.path.exists(font_path):
self.fail(f"font file not found: {font_path}")
# test english text wrapping
test_text_en = "This is a test text for wrapping long sentences in english language"
wrapped_text_en, text_height_en = vd.wrap_text(
text=test_text_en,
max_width=300,
font=font_path,
fontsize=30
)
print(wrapped_text_en, text_height_en)
# verify text is wrapped
self.assertIn("\n", wrapped_text_en)
# test chinese text wrapping
test_text_zh = "这是一段用来测试中文长句换行的文本内容,应该会根据宽度限制进行换行处理"
wrapped_text_zh, text_height_zh = vd.wrap_text(
text=test_text_zh,
max_width=300,
font=font_path,
fontsize=30
)
print(wrapped_text_zh, text_height_zh)
# verify chinese text is wrapped
self.assertIn("\n", wrapped_text_zh)
except Exception as e:
self.fail(f"test wrap_text failed: {str(e)}")
if __name__ == "__main__":
unittest.main()

487
test/services/test_voice.py Normal file
View File

@@ -0,0 +1,487 @@
import asyncio
import base64
import unittest
import os
import sys
import tempfile
import time
from datetime import timedelta
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
# add project root to python path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from app.utils import utils
from app.services import voice as vs
from app.services import task as task_service
from pydub import AudioSegment
temp_dir = utils.storage_dir("temp")
text_en = """
What is the meaning of life?
This question has puzzled philosophers, scientists, and thinkers of all kinds for centuries.
Throughout history, various cultures and individuals have come up with their interpretations and beliefs around the purpose of life.
Some say it's to seek happiness and self-fulfillment, while others believe it's about contributing to the welfare of others and making a positive impact in the world.
Despite the myriad of perspectives, one thing remains clear: the meaning of life is a deeply personal concept that varies from one person to another.
It's an existential inquiry that encourages us to reflect on our values, desires, and the essence of our existence.
"""
text_zh = """
预计未来3天深圳冷空气活动频繁未来两天持续阴天有小雨出门带好雨具
10-11日持续阴天有小雨日温差小气温在13-17℃之间体感阴凉
12日天气短暂好转早晚清凉
"""
voice_rate=1.0
voice_volume=1.0
class TestVoiceService(unittest.TestCase):
def setUp(self):
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
def tearDown(self):
self.loop.close()
def test_siliconflow(self):
# SiliconFlow 的 API Key 存在 [siliconflow].api_key 中,运行时代码也是从
# config.siliconflow 读取;这里必须使用同一配置源,避免正确配置凭据时
# 测试仍然被误跳过。
if not vs.config.siliconflow.get("api_key"):
self.skipTest("siliconflow_api_key is not configured")
voice_name = "siliconflow:FunAudioLLM/CosyVoice2-0.5B:alex-Male"
voice_name = vs.parse_voice_name(voice_name)
async def _do():
parts = voice_name.split(":")
if len(parts) >= 3:
model = parts[1]
# 移除性别后缀,例如 "alex-Male" -> "alex"
voice_with_gender = parts[2]
voice = voice_with_gender.split("-")[0]
# 构建完整的voice参数格式为 "model:voice"
full_voice = f"{model}:{voice}"
voice_file = f"{temp_dir}/tts-siliconflow-{voice}.mp3"
subtitle_file = f"{temp_dir}/tts-siliconflow-{voice}.srt"
sub_maker = vs.siliconflow_tts(
text=text_zh, model=model, voice=full_voice, voice_file=voice_file, voice_rate=voice_rate, voice_volume=voice_volume
)
if not sub_maker:
self.fail("siliconflow tts failed")
vs.create_subtitle(sub_maker=sub_maker, text=text_zh, subtitle_file=subtitle_file)
audio_duration = vs.get_audio_duration(sub_maker)
print(f"voice: {voice_name}, audio duration: {audio_duration}s")
else:
self.fail("siliconflow invalid voice name")
self.loop.run_until_complete(_do())
def test_azure_tts_v1(self):
voice_name = "zh-CN-XiaoyiNeural-Female"
voice_name = vs.parse_voice_name(voice_name)
print(voice_name)
voice_file = f"{temp_dir}/tts-azure-v1-{voice_name}.mp3"
subtitle_file = f"{temp_dir}/tts-azure-v1-{voice_name}.srt"
sub_maker = vs.azure_tts_v1(
text=text_zh, voice_name=voice_name, voice_file=voice_file, voice_rate=voice_rate
)
if not sub_maker:
self.fail("azure tts v1 failed")
vs.create_subtitle(sub_maker=sub_maker, text=text_zh, subtitle_file=subtitle_file)
audio_duration = vs.get_audio_duration(sub_maker)
print(f"voice: {voice_name}, audio duration: {audio_duration}s")
def test_azure_tts_v1_supports_legacy_edge_tts_without_boundary(self):
"""
验证 Azure TTS V1 在旧版 edge_tts 依赖残留时仍可继续工作。
这个回归场景对应 Windows 便携包更新失败后,现场环境还停留在旧版
edge_tts 的情况:
1. `Communicate.__init__()` 不接受 `boundary`
2. 只有异步 `stream()`,没有 `stream_sync()`
"""
class _LegacyCommunicate:
def __init__(self, text, voice, rate="+0%"):
self.text = text
self.voice = voice
self.rate = rate
async def stream(self):
yield {"type": "audio", "data": b"legacy-audio"}
yield {
"type": "WordBoundary",
"offset": 0,
"duration": 10000000,
"text": "legacy",
}
class _FakeSubMaker:
def __init__(self):
self.events = []
def feed(self, chunk):
self.events.append(chunk)
def get_srt(self):
if not self.events:
return ""
return "1\n00:00:00,000 --> 00:00:01,000\nlegacy\n"
with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
vs.edge_tts, "Communicate", _LegacyCommunicate
), patch.object(vs.edge_tts, "SubMaker", _FakeSubMaker):
voice_file = str(Path(tmp_dir) / "legacy-edge-tts.mp3")
sub_maker = vs.azure_tts_v1(
text="legacy edge tts compatibility",
voice_name="zh-CN-XiaoyiNeural-Female",
voice_file=voice_file,
voice_rate=1.0,
)
self.assertIsNotNone(sub_maker)
self.assertEqual(Path(voice_file).read_bytes(), b"legacy-audio")
self.assertEqual(len(sub_maker.events), 1)
self.assertEqual(sub_maker.events[0]["type"], "WordBoundary")
def test_azure_tts_v1_times_out_hanging_stream_sync(self):
"""
验证 Azure TTS V1 在 edge_tts 同步流卡住时能够快速失败。
真实现场里网络异常、服务端限流、voice 语言与文本不匹配时,
`stream_sync()` 可能长时间不返回,导致 WebUI 任务只停在
`start, voice name...`。这里用阻塞的 fake stream 复现该场景,
确认超时保护会让函数结束并返回 None。
"""
class _HangingCommunicate:
def __init__(self, text, voice, rate="+0%", boundary=None):
self.text = text
self.voice = voice
self.rate = rate
self.boundary = boundary
def stream_sync(self):
time.sleep(10)
yield {"type": "audio", "data": b"unreachable"}
class _FakeSubMaker:
def feed(self, chunk):
return None
def get_srt(self):
return ""
with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
vs.edge_tts, "Communicate", _HangingCommunicate
), patch.object(vs.edge_tts, "SubMaker", _FakeSubMaker), patch.object(
vs.config,
"app",
dict(vs.config.app, edge_tts_timeout=0.05),
):
voice_file = Path(tmp_dir) / "hanging-edge-tts.mp3"
started_at = time.monotonic()
sub_maker = vs.azure_tts_v1(
text="帮我生成一个花开花落的视频",
voice_name="en-AU-NatashaNeural-Female",
voice_file=str(voice_file),
voice_rate=1.0,
)
elapsed = time.monotonic() - started_at
self.assertFalse(voice_file.exists())
self.assertIsNone(sub_maker)
self.assertLess(elapsed, 2)
def test_azure_tts_v2(self):
if not vs.config.azure.get("speech_key") or not vs.config.azure.get("speech_region"):
self.skipTest("Azure speech key or region is not configured")
voice_name = "zh-CN-XiaoxiaoMultilingualNeural-V2-Female"
voice_name = vs.parse_voice_name(voice_name)
print(voice_name)
async def _do():
voice_file = f"{temp_dir}/tts-azure-v2-{voice_name}.mp3"
subtitle_file = f"{temp_dir}/tts-azure-v2-{voice_name}.srt"
sub_maker = vs.azure_tts_v2(
text=text_zh, voice_name=voice_name, voice_file=voice_file
)
if not sub_maker:
self.fail("azure tts v2 failed")
vs.create_subtitle(sub_maker=sub_maker, text=text_zh, subtitle_file=subtitle_file)
audio_duration = vs.get_audio_duration(sub_maker)
print(f"voice: {voice_name}, audio duration: {audio_duration}s")
self.loop.run_until_complete(_do())
def test_gemini_tts_uses_legacy_submaker_fields(self):
"""
验证 Gemini TTS 在 edge_tts 7.x 环境下仍会返回项目兼容的字幕结构,
并且可以被 `subtitle_provider=edge` 的字幕生成链路直接消费,
避免再次回退 Whisper。
"""
class _InlineData:
def __init__(self, data):
self.data = data
class _Part:
def __init__(self, data):
self.inline_data = _InlineData(data)
class _Content:
def __init__(self, data):
self.parts = [_Part(data)]
class _Candidate:
def __init__(self, data):
self.content = _Content(data)
class _Response:
def __init__(self, data):
self.candidates = [_Candidate(data)]
class _FakeModel:
def __init__(self, name):
self.name = name
def generate_content(self, contents, generation_config):
tone = (
AudioSegment.silent(duration=1800)
.set_frame_rate(24000)
.set_channels(1)
.set_sample_width(2)
)
return _Response(tone.raw_data)
voice_file = f"{temp_dir}/tts-gemini-Zephyr.mp3"
subtitle_file = f"{temp_dir}/tts-gemini-Zephyr.srt"
text = "Gemini subtitle generation should work now. Testing multiple lines."
with patch("google.generativeai.configure"), patch(
"google.generativeai.GenerativeModel", _FakeModel
), patch.object(vs.config, "app", dict(vs.config.app, gemini_api_key="test-key")):
sub_maker = vs.gemini_tts(
text=text,
voice_name="Zephyr",
voice_rate=1.0,
voice_file=voice_file,
)
self.assertIsNotNone(sub_maker)
self.assertEqual(
getattr(sub_maker, "subs", []),
["Gemini subtitle generation should work now", "Testing multiple lines"],
)
self.assertEqual(len(getattr(sub_maker, "offset", [])), 2)
self.assertEqual(sub_maker.offset[0][0], 0)
self.assertLess(sub_maker.offset[0][1], sub_maker.offset[1][1])
vs.create_subtitle(sub_maker=sub_maker, text=text, subtitle_file=subtitle_file)
subtitle_content = Path(subtitle_file).read_text(encoding="utf-8")
self.assertIn("Gemini subtitle generation should work now", subtitle_content)
self.assertIn("Testing multiple lines", subtitle_content)
def test_mimo_tts_uses_openai_compatible_audio_response(self):
"""
验证 Xiaomi MiMo TTS 可以消费 OpenAI-compatible 的音频响应结构。
这里用 fake OpenAI client 和 fake AudioSegment 覆盖真实网络与 ffmpeg
确认运行时代码会把待合成文本放到 assistant message并把返回的
base64 WAV 音频导出到项目后续流程使用的音频文件。
"""
class _FakeAudio:
def __init__(self):
self.data = base64.b64encode(b"RIFF-fake-wav").decode("utf-8")
class _FakeMessage:
def __init__(self):
self.audio = _FakeAudio()
class _FakeChoice:
def __init__(self):
self.message = _FakeMessage()
class _FakeCompletion:
def __init__(self):
self.choices = [_FakeChoice()]
class _FakeCompletions:
def create(self, **kwargs):
self.kwargs = kwargs
return _FakeCompletion()
class _FakeAudioSegment:
def __len__(self):
return 1800
def export(self, output_file, format):
Path(output_file).write_bytes(b"fake-mp3")
fake_completions = _FakeCompletions()
fake_client = SimpleNamespace(
chat=SimpleNamespace(completions=fake_completions)
)
with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
vs,
"OpenAI",
return_value=fake_client,
) as openai_client, patch(
"pydub.AudioSegment.from_file",
return_value=_FakeAudioSegment(),
), patch.object(
vs.config,
"app",
dict(
vs.config.app,
mimo_api_key="mimo-key",
mimo_base_url="https://api.xiaomimimo.com/v1",
mimo_tts_model_name="mimo-v2.5-tts",
mimo_tts_style_prompt="用清晰的中文旁白朗读。",
),
):
voice_file = str(Path(tmp_dir) / "mimo-tts.mp3")
sub_maker = vs.mimo_tts(
text="小米语音合成测试。第二句话。",
voice_name="冰糖",
voice_rate=1.0,
voice_file=voice_file,
voice_volume=1.0,
)
generated_audio = Path(voice_file).read_bytes()
openai_client.assert_called_once_with(
api_key="mimo-key",
base_url="https://api.xiaomimimo.com/v1",
)
self.assertEqual(fake_completions.kwargs["model"], "mimo-v2.5-tts")
self.assertEqual(
fake_completions.kwargs["messages"],
[
{"role": "user", "content": "用清晰的中文旁白朗读。"},
{"role": "assistant", "content": "小米语音合成测试。第二句话。"},
],
)
self.assertEqual(
fake_completions.kwargs["audio"],
{"format": "wav", "voice": "冰糖"},
)
self.assertEqual(generated_audio, b"fake-mp3")
self.assertIsNotNone(sub_maker)
self.assertEqual(getattr(sub_maker, "subs", []), ["小米语音合成测试", "第二句话"])
self.assertEqual(len(getattr(sub_maker, "offset", [])), 2)
def test_generate_subtitle_keeps_edge_provider_for_gemini_legacy_submaker(self):
"""
验证 Gemini TTS 返回的 legacy 字幕结构在 edge provider 下可以直接产出
SRT不会因为匹配失败而回退到 Whisper。
"""
script = "Gemini subtitle generation should work now. Testing multiple lines."
sub_maker = vs.populate_legacy_submaker_with_full_text(
vs.ensure_legacy_submaker_fields(vs.SubMaker()),
script,
2.4,
)
with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
task_service.config,
"app",
dict(task_service.config.app, subtitle_provider="edge"),
), patch("app.services.subtitle.create") as whisper_create, patch(
"app.utils.utils.task_dir",
lambda tid="": str(Path(tmp_dir) / tid) if tid else str(Path(tmp_dir)),
):
task_id = "gemini-subtitle-edge-task"
Path(tmp_dir, task_id).mkdir(parents=True, exist_ok=True)
subtitle_path = task_service.generate_subtitle(
task_id=task_id,
params=type("Params", (), {"subtitle_enabled": True})(),
video_script=script,
sub_maker=sub_maker,
audio_file="",
)
self.assertTrue(subtitle_path.endswith("subtitle.srt"))
self.assertTrue(Path(subtitle_path).exists())
self.assertFalse(whisper_create.called)
subtitle_content = Path(subtitle_path).read_text(encoding="utf-8")
self.assertIn("Gemini subtitle generation should work now", subtitle_content)
self.assertIn("Testing multiple lines", subtitle_content)
def test_script_split_keeps_thousand_separator_comma(self):
"""
Edge TTS 会把 "1,000 years" 作为连续文本返回。脚本断句时不能把
数字中间的英文逗号当成句子边界,否则字幕聚合会出现 issue #894
里的 sub_items 数量少于 script_lines并错误回退 Whisper。
"""
text = (
"It takes about 1,000 years for a single drop of water to finish "
"the whole trip!"
)
self.assertEqual(
utils.split_string_by_punctuations(text),
[
(
"It takes about 1,000 years for a single drop of water to finish "
"the whole trip"
)
],
)
def test_edge_cue_aggregation_handles_thousand_separator_comma(self):
"""
复现 issue #894 的关键形态Edge cues 中最后一句作为连续文本返回,
包含 `1,000 years`。脚本断句必须与 cues 聚合结果一致,不能把它
拆成两条字幕。
"""
text = (
"The ocean isn't just sitting stil, it moves around the world like a massive "
"amusement park ride! Cold water at the North and South Poles sinks to the "
"bottom because it is heavy and salty. At the same time, warm water from the "
"sunny equator flows along the top to take its place. This creates a giant "
"underwater conveyor belt that travels all the way around the Earth. It takes "
"about 1,000 years for a single drop of water to finish the whole trip!"
)
script_lines = utils.split_string_by_punctuations(text)
cues = []
for index, line in enumerate(script_lines):
# Edge 的 cue content 经常没有脚本里的空格和标点布局,这里去掉空格
# 来模拟更严格的匹配场景。
cues.append(
SimpleNamespace(
content=line.replace(" ", ""),
start=timedelta(seconds=index),
end=timedelta(seconds=index + 0.8),
)
)
sub_maker = SimpleNamespace(cues=cues)
sub_items = vs._build_subtitle_items_from_edge_cues(sub_maker, script_lines)
self.assertEqual(len(sub_items), len(script_lines))
self.assertIn("1,000 years", sub_items[-1])
def test_convert_rate_to_percent_signs_zero_rate(self):
# Rates near but not exactly 1.0 round to 0 percent. edge-tts rejects
# an unsigned "0%" (ValueError: Invalid rate '0%'), so the helper must
# emit a sign-prefixed "+0%". Regression test for that crash.
self.assertEqual(vs.convert_rate_to_percent(1.0), "+0%")
self.assertEqual(vs.convert_rate_to_percent(1.004), "+0%")
self.assertEqual(vs.convert_rate_to_percent(0.997), "+0%")
self.assertEqual(vs.convert_rate_to_percent(1.5), "+50%")
self.assertEqual(vs.convert_rate_to_percent(0.8), "-20%")
if __name__ == "__main__":
# python -m unittest test.services.test_voice.TestVoiceService.test_azure_tts_v1
# python -m unittest test.services.test_voice.TestVoiceService.test_azure_tts_v2
unittest.main()