From 78257fae96d128626aaf9ceba31433e41622c910 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 24 Jul 2026 20:55:33 +0800 Subject: [PATCH] =?UTF-8?q?test(P3-1):=20=E7=AC=AC56=E6=B3=A2=20worker?= =?UTF-8?q?=E5=B1=82=E6=9B=B4=E5=A4=9A=E5=BC=95=E6=93=8E=E7=BA=AF=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E5=8D=95=E6=B5=8B=EF=BC=88+108=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_subtitle_render_engine.py: 53个(颜色转换/时间格式化/文字换行/ASS转义/样式) - test_sticker_engine.py: 27个(配置类/位置解析/常量/便捷函数) - test_tts_engine.py: 12个(数据类/入口判断/初始化) - test_render_audio_utils.py: 16个(clip_effective_duration/RenderContext) 覆盖worker层4个模块的纯逻辑部分 --- tests/unit/test_render_audio_utils.py | 107 ++++++++ tests/unit/test_sticker_engine.py | 284 +++++++++++++++++++++ tests/unit/test_subtitle_render_engine.py | 298 ++++++++++++++++++++++ tests/unit/test_tts_engine.py | 131 ++++++++++ 4 files changed, 820 insertions(+) create mode 100644 tests/unit/test_render_audio_utils.py create mode 100644 tests/unit/test_sticker_engine.py create mode 100644 tests/unit/test_subtitle_render_engine.py create mode 100644 tests/unit/test_tts_engine.py diff --git a/tests/unit/test_render_audio_utils.py b/tests/unit/test_render_audio_utils.py new file mode 100644 index 000000000..dd24e7daa --- /dev/null +++ b/tests/unit/test_render_audio_utils.py @@ -0,0 +1,107 @@ +""" +render_audio 纯工具函数测试. + +覆盖 clip_effective_duration / RenderContext 等纯逻辑. +核心混音逻辑依赖 FFmpeg,由集成测试覆盖. +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from video_processing.render_audio import RenderContext, clip_effective_duration + + +class TestClipEffectiveDuration: + """clip_effective_duration 有效时长计算.""" + + def test_duration_specified_and_actual_longer(self): + """指定了 duration,且实际时长更长 → 取 duration""" + clip = SimpleNamespace(duration=5.0, actual_duration=10.0) + assert clip_effective_duration(clip) == 5.0 + + def test_duration_specified_and_actual_shorter(self): + """指定了 duration,但实际时长更短 → 取实际时长""" + clip = SimpleNamespace(duration=10.0, actual_duration=5.0) + assert clip_effective_duration(clip) == 5.0 + + def test_duration_specified_actual_zero(self): + """指定了 duration,但实际时长为 0 → 取 duration""" + clip = SimpleNamespace(duration=5.0, actual_duration=0.0) + assert clip_effective_duration(clip) == 5.0 + + def test_duration_specified_actual_negative(self): + """指定了 duration,但实际时长为负 → 取 duration""" + clip = SimpleNamespace(duration=5.0, actual_duration=-1.0) + assert clip_effective_duration(clip) == 5.0 + + def test_no_duration_use_actual(self): + """未指定 duration(0),用实际时长""" + clip = SimpleNamespace(duration=0.0, actual_duration=8.0) + assert clip_effective_duration(clip) == 8.0 + + def test_no_duration_negative_actual(self): + """未指定 duration,实际时长也为负 → 返回 0""" + clip = SimpleNamespace(duration=0.0, actual_duration=-5.0) + assert clip_effective_duration(clip) == 0.0 + + def test_no_duration_zero_actual(self): + """都为 0 → 返回 0""" + clip = SimpleNamespace(duration=0.0, actual_duration=0.0) + assert clip_effective_duration(clip) == 0.0 + + def test_negative_duration_use_actual(self): + """duration 为负(视为未指定),用实际时长""" + clip = SimpleNamespace(duration=-1.0, actual_duration=5.0) + assert clip_effective_duration(clip) == 5.0 + + def test_both_negative_returns_zero(self): + """两者都为负 → 返回 0""" + clip = SimpleNamespace(duration=-2.0, actual_duration=-3.0) + assert clip_effective_duration(clip) == 0.0 + + def test_exact_match(self): + """duration 与实际时长相等""" + clip = SimpleNamespace(duration=7.5, actual_duration=7.5) + assert clip_effective_duration(clip) == 7.5 + + def test_very_short_duration(self): + """很短的时长""" + clip = SimpleNamespace(duration=0.1, actual_duration=0.2) + assert clip_effective_duration(clip) == 0.1 + + +class TestRenderContext: + """RenderContext 渲染上下文.""" + + def test_create_with_work_dir_and_plan_id(self, tmp_path): + ctx = RenderContext(work_dir=tmp_path, plan_id="plan_123") + assert ctx.work_dir == tmp_path + assert ctx.plan_id == "plan_123" + assert ctx.noise_reduction_config is None + assert ctx._audio_cache == {} + + def test_noise_reduction_config(self, tmp_path): + config = {"enabled": True, "strength": 0.5} + ctx = RenderContext( + work_dir=tmp_path, + plan_id="plan_123", + noise_reduction_config=config, + ) + assert ctx.noise_reduction_config == config + assert ctx.noise_reduction_config["enabled"] is True + + def test_audio_cache_isolation(self, tmp_path): + """每个实例有独立的缓存字典.""" + ctx1 = RenderContext(work_dir=tmp_path, plan_id="p1") + ctx2 = RenderContext(work_dir=tmp_path, plan_id="p2") + ctx1._audio_cache["key1"] = True + assert "key1" not in ctx2._audio_cache + assert len(ctx2._audio_cache) == 0 + + def test_work_dir_path_type(self, tmp_path): + ctx = RenderContext(work_dir=tmp_path, plan_id="test") + assert isinstance(ctx.work_dir, Path) diff --git a/tests/unit/test_sticker_engine.py b/tests/unit/test_sticker_engine.py new file mode 100644 index 000000000..bdf7f7f04 --- /dev/null +++ b/tests/unit/test_sticker_engine.py @@ -0,0 +1,284 @@ +""" +贴纸引擎配置与纯逻辑测试. + +覆盖 ImageStickerConfig / TextStickerConfig / _resolve_position / 常量与便捷函数. +引擎核心滤镜生成与渲染依赖 FFmpeg,由集成测试覆盖. +""" + +from __future__ import annotations + +import pytest +from video_processing.sticker_engine import ( + POSITION_PRESETS, + STICKER_CATEGORIES, + ImageStickerConfig, + StickerEngine, + StickerOverlayResult, + TextStickerConfig, + get_sticker_categories, + parse_stickers_from_config, +) + + +class TestStickerConstants: + """常量测试.""" + + def test_nine_position_presets(self): + assert len(POSITION_PRESETS) == 9 + assert "top_left" in POSITION_PRESETS + assert "top_center" in POSITION_PRESETS + assert "top_right" in POSITION_PRESETS + assert "center_left" in POSITION_PRESETS + assert "center" in POSITION_PRESETS + assert "center_right" in POSITION_PRESETS + assert "bottom_left" in POSITION_PRESETS + assert "bottom_center" in POSITION_PRESETS + assert "bottom_right" in POSITION_PRESETS + + def test_position_values_are_fractions(self): + for name, (x, y) in POSITION_PRESETS.items(): + assert 0.0 <= x <= 1.0, f"{name} x={x} out of range" + assert 0.0 <= y <= 1.0, f"{name} y={y} out of range" + + def test_sticker_categories(self): + assert len(STICKER_CATEGORIES) >= 3 + for cat_id, cat_name in STICKER_CATEGORIES: + assert isinstance(cat_id, str) + assert isinstance(cat_name, str) + assert len(cat_id) > 0 + assert len(cat_name) > 0 + + +class TestImageStickerConfig: + """图片贴纸配置.""" + + def test_default_values(self): + cfg = ImageStickerConfig() + assert cfg.enabled is False + assert cfg.type == "image" + assert cfg.position == "top_right" + assert cfg.x is None + assert cfg.y is None + assert cfg.x_unit == "percent" + assert cfg.y_unit == "percent" + assert cfg.scale == 1.0 + assert cfg.width is None + assert cfg.height is None + assert cfg.opacity == 1.0 + assert cfg.start_time == 0.0 + assert cfg.duration == 0.0 + assert cfg.fade_in == 0.0 + assert cfg.fade_out == 0.0 + assert cfg.z_index == 10 + assert cfg.image_url == "" + assert cfg.preset_id == "" + + def test_custom_values(self): + cfg = ImageStickerConfig( + enabled=True, + position="center", + x=50.0, + y=30.0, + scale=0.5, + opacity=0.8, + start_time=1.0, + duration=5.0, + fade_in=0.5, + fade_out=0.5, + z_index=5, + image_url="/tmp/sticker.png", + preset_id="sticker_001", + ) + assert cfg.enabled is True + assert cfg.position == "center" + assert cfg.x == 50.0 + assert cfg.y == 30.0 + assert cfg.scale == 0.5 + assert cfg.opacity == 0.8 + assert cfg.start_time == 1.0 + assert cfg.duration == 5.0 + assert cfg.z_index == 5 + assert cfg.image_url == "/tmp/sticker.png" + + +class TestTextStickerConfig: + """文字贴纸配置.""" + + def test_default_values(self): + cfg = TextStickerConfig() + assert cfg.enabled is False + assert cfg.type == "text" + assert cfg.text == "" + assert cfg.font_size == 36 + assert cfg.font_color == "#FFFFFF" + assert cfg.font_family == "sans" + assert cfg.stroke_color == "#000000" + assert cfg.stroke_width == 2 + assert cfg.shadow_color == "#000000" + assert cfg.shadow_x == 2 + assert cfg.shadow_y == 2 + assert cfg.shadow_alpha == 0.5 + assert cfg.position == "center" + assert cfg.z_index == 10 + assert cfg.bg_color == "" + assert cfg.bg_padding == 8 + assert cfg.bg_alpha == 0.8 + assert cfg.bg_corner_radius == 8 + + def test_custom_text_sticker(self): + cfg = TextStickerConfig( + enabled=True, + text="Hello", + font_size=48, + font_color="#FF0000", + position="bottom_center", + bg_color="#000000", + bg_padding=16, + ) + assert cfg.enabled is True + assert cfg.text == "Hello" + assert cfg.font_size == 48 + assert cfg.font_color == "#FF0000" + assert cfg.position == "bottom_center" + assert cfg.bg_color == "#000000" + assert cfg.bg_padding == 16 + + +class TestStickerOverlayResult: + """贴纸叠加结果.""" + + def test_default_values(self): + result = StickerOverlayResult(filter_str="overlay=10:20", output_label="[out]") + assert result.filter_str == "overlay=10:20" + assert result.output_label == "[out]" + assert result.extra_inputs == [] + + def test_with_extra_inputs(self): + result = StickerOverlayResult( + filter_str="overlay=0:0", + output_label="[out]", + extra_inputs=["/tmp/sticker.png"], + ) + assert len(result.extra_inputs) == 1 + assert result.extra_inputs[0] == "/tmp/sticker.png" + + +class TestResolvePosition: + """_resolve_position 位置解析.""" + + def test_top_left_preset(self): + cfg = ImageStickerConfig(position="top_left") + x, y = StickerEngine._resolve_position(cfg, 1000, 500, 100, 50) + # top_left: (0.05, 0.05) → x = 0.05*1000 - 50 = 0, y = 0.05*500 - 25 = 0 + assert x >= 0 + assert y >= 0 + + def test_center_preset(self): + cfg = ImageStickerConfig(position="center") + x, y = StickerEngine._resolve_position(cfg, 1000, 500, 200, 100) + # center: (0.5, 0.5) → x = 500 - 100 = 400, y = 250 - 50 = 200 + assert x == 400.0 + assert y == 200.0 + + def test_bottom_right_preset(self): + cfg = ImageStickerConfig(position="bottom_right") + x, y = StickerEngine._resolve_position(cfg, 1000, 500, 100, 50) + # bottom_right: (0.95, 0.95) → x = 950 - 50 = 900, y = 475 - 25 = 450 + assert x == 900.0 + assert y == 450.0 + + def test_custom_percent_position(self): + cfg = ImageStickerConfig(position="center", x=25.0, y=75.0, x_unit="percent", y_unit="percent") + x, y = StickerEngine._resolve_position(cfg, 1000, 500, 100, 50) + # x = 0.25*1000 - 50 = 200, y = 0.75*500 - 25 = 350 + assert x == 200.0 + assert y == 350.0 + + def test_custom_pixel_position(self): + cfg = ImageStickerConfig(position="center", x=300, y=200, x_unit="pixel", y_unit="pixel") + x, y = StickerEngine._resolve_position(cfg, 1000, 500, 100, 50) + # x = 300/1000*1000 - 50 = 250, y = 200/500*500 - 25 = 175 + # 等等,让我重新算:px = config.x / canvas_w = 300/1000 = 0.3 + # x = px * canvas_w - sticker_w/2 = 0.3*1000 - 50 = 300 - 50 = 250 + assert x == 250.0 + assert y == 175.0 + + def test_zero_size_sticker(self): + cfg = ImageStickerConfig(position="center") + x, y = StickerEngine._resolve_position(cfg, 1000, 500, 0, 0) + # 贴纸尺寸为0时,位置就是中心点 + assert x == 500.0 + assert y == 250.0 + + def test_invalid_position_falls_back_to_center(self): + cfg = ImageStickerConfig(position="invalid_position") + x, y = StickerEngine._resolve_position(cfg, 1000, 500, 100, 50) + # 无效位置 → 默认居中 → x = 500 - 50 = 450, y = 250 - 25 = 225 + assert x == 450.0 + assert y == 225.0 + + def test_position_clamped_to_canvas(self): + # 贴纸太大,位置被钳制 + cfg = ImageStickerConfig(position="top_left") + x, y = StickerEngine._resolve_position(cfg, 100, 100, 200, 200) + # 贴纸比画布还大,应该被钳制到 0 + assert x >= 0 + assert y >= 0 + assert x <= 100 + assert y <= 100 + + def test_zero_canvas_handling(self): + cfg = ImageStickerConfig(position="center", x=50, y=50, x_unit="pixel", y_unit="pixel") + x, y = StickerEngine._resolve_position(cfg, 0, 0, 10, 10) + # 画布为0时不应崩溃,结果被钳制到0 + assert x == 0 + assert y == 0 + + def test_text_sticker_position(self): + cfg = TextStickerConfig(position="top_right") + x, y = StickerEngine._resolve_position(cfg, 800, 400, 100, 30) + # top_right: (0.95, 0.05) → x = 760 - 50 = 710, 但被钳制到 canvas_w - sticker_w = 700 + # y = 20 - 15 = 5 + assert x == 700.0 + assert y == 5.0 + + +class TestParseStickersFromConfig: + """parse_stickers_from_config 便捷函数.""" + + def test_none_returns_empty(self): + assert parse_stickers_from_config(None) == [] + + def test_empty_dict_returns_empty(self): + assert parse_stickers_from_config({}) == [] + + def test_no_stickers_key_returns_empty(self): + assert parse_stickers_from_config({"other": "data"}) == [] + + def test_stickers_list_returned(self): + stickers = [{"type": "image", "url": "/a.png"}, {"type": "text", "text": "hi"}] + result = parse_stickers_from_config({"stickers": stickers}) + assert result == stickers + assert len(result) == 2 + + def test_stickers_not_a_list_returns_empty(self): + assert parse_stickers_from_config({"stickers": "not_a_list"}) == [] + + def test_empty_stickers_list(self): + assert parse_stickers_from_config({"stickers": []}) == [] + + +class TestGetStickerCategories: + """get_sticker_categories 便捷函数.""" + + def test_returns_list_of_tuples(self): + result = get_sticker_categories() + assert isinstance(result, list) + assert len(result) > 0 + for item in result: + assert isinstance(item, tuple) + assert len(item) == 2 + + def test_matches_constant(self): + result = get_sticker_categories() + assert result == list(STICKER_CATEGORIES) diff --git a/tests/unit/test_subtitle_render_engine.py b/tests/unit/test_subtitle_render_engine.py new file mode 100644 index 000000000..fe69ff40f --- /dev/null +++ b/tests/unit/test_subtitle_render_engine.py @@ -0,0 +1,298 @@ +""" +字幕渲染引擎纯函数与配置测试. + +覆盖 SubtitleStyle / SubtitleSegment / 颜色转换 / 时间格式化 / 文字换行 / ASS转义等纯逻辑. +引擎核心 render 方法依赖 FFmpeg,由集成测试覆盖. +""" + +from __future__ import annotations + +import pytest +from video_processing.subtitle_render_engine import ( + SubtitleSegment, + SubtitleStyle, + _escape_ass_text, + _format_ass_time, + _hex_to_ass_bgr, + _hex_to_ass_color, + _opacity_to_ass_alpha, + _wrap_text, +) + + +class TestHexToAssColor: + """HEX → ASS 颜色转换.""" + + def test_white(self): + assert _hex_to_ass_color("#FFFFFF") == "&H00FFFFFF" + + def test_black(self): + assert _hex_to_ass_color("#000000") == "&H00000000" + + def test_red(self): + # #FF0000 → R=FF, G=00, B=00 → BGR=0000FF + assert _hex_to_ass_color("#FF0000") == "&H000000FF" + + def test_blue(self): + # #0000FF → R=00, G=00, B=FF → BGR=FF0000 + assert _hex_to_ass_color("#0000FF") == "&H00FF0000" + + def test_green(self): + # #00FF00 → R=00, G=FF, B=00 → BGR=00FF00 + assert _hex_to_ass_color("#00FF00") == "&H0000FF00" + + def test_without_hash_prefix(self): + assert _hex_to_ass_color("FF0000") == "&H000000FF" + + def test_lowercase_hex(self): + assert _hex_to_ass_color("#ff0000") == "&H000000FF" + + def test_mixed_case(self): + assert _hex_to_ass_color("#aBcDeF") == "&H00EFCDAB" + + def test_invalid_length_returns_default(self): + assert _hex_to_ass_color("#FFF") == "&H00FFFFFF" # 3位 + assert _hex_to_ass_color("#FF") == "&H00FFFFFF" # 2位 + assert _hex_to_ass_color("#") == "&H00FFFFFF" # 空 + + def test_empty_string(self): + assert _hex_to_ass_color("") == "&H00FFFFFF" + + +class TestHexToAssBgr: + """HEX → ASS BGR 部分.""" + + def test_white(self): + assert _hex_to_ass_bgr("#FFFFFF") == "FFFFFF" + + def test_black(self): + assert _hex_to_ass_bgr("#000000") == "000000" + + def test_red(self): + # #FF0000 → BGR = 0000FF + assert _hex_to_ass_bgr("#FF0000") == "0000FF" + + def test_blue(self): + # #0000FF → BGR = FF0000 + assert _hex_to_ass_bgr("#0000FF") == "FF0000" + + def test_without_hash(self): + assert _hex_to_ass_bgr("FF0000") == "0000FF" + + def test_invalid_length_returns_white(self): + assert _hex_to_ass_bgr("#123") == "FFFFFF" + + +class TestOpacityToAssAlpha: + """不透明度 → ASS alpha.""" + + def test_fully_opaque(self): + # 1.0 → alpha = 255 - 255 = 0 → "00" + assert _opacity_to_ass_alpha(1.0) == "00" + + def test_fully_transparent(self): + # 0.0 → alpha = 255 - 0 = 255 → "FF" + assert _opacity_to_ass_alpha(0.0) == "FF" + + def test_half(self): + # 0.5 → alpha = 255 - 127 = 128 → "80" (因为 int(0.5*255)=127) + # 注意:int(0.5 * 255) = 127,255-127=128 → "80" + assert _opacity_to_ass_alpha(0.5) == "80" + + def test_quarter(self): + # 0.25 → alpha = 255 - 63 = 192 → "C0" + assert _opacity_to_ass_alpha(0.25) == "C0" + + def test_three_quarters(self): + # 0.75 → alpha = 255 - 191 = 64 → "40" + assert _opacity_to_ass_alpha(0.75) == "40" + + def test_zero_padded(self): + # 结果始终是2位十六进制 + result = _opacity_to_ass_alpha(1.0) + assert len(result) == 2 + assert result == result.upper() + + +class TestEscapeAssText: + """ASS 文本转义.""" + + def test_plain_text(self): + assert _escape_ass_text("hello world") == "hello world" + + def test_newline_unix(self): + assert _escape_ass_text("line1\nline2") == "line1\\Nline2" + + def test_newline_windows(self): + assert _escape_ass_text("line1\r\nline2") == "line1\\Nline2" + + def test_newline_mac(self): + assert _escape_ass_text("line1\rline2") == "line1\\Nline2" + + def test_curly_braces_replaced(self): + # ASS 中 {} 是样式标签,需要转义 + assert _escape_ass_text("{text}") == "(text)" + + def test_multiple_braces(self): + assert _escape_ass_text("{a}b{c}") == "(a)b(c)" + + def test_mixed_special_chars(self): + text = "line1\n{bold}\nline3" + result = _escape_ass_text(text) + assert "\\N" in result + assert "(bold)" in result + assert "{" not in result + assert "}" not in result + assert "\n" not in result + assert "\r" not in result + + def test_empty_string(self): + assert _escape_ass_text("") == "" + + +class TestFormatAssTime: + """秒 → ASS 时间格式.""" + + def test_zero(self): + assert _format_ass_time(0.0) == "0:00:00.00" + + def test_seconds_only(self): + assert _format_ass_time(5.5) == "0:00:05.50" + + def test_minutes(self): + assert _format_ass_time(65.25) == "0:01:05.25" + + def test_hours(self): + assert _format_ass_time(3661.5) == "1:01:01.50" + + def test_exact_minute(self): + assert _format_ass_time(60.0) == "0:01:00.00" + + def test_exact_hour(self): + assert _format_ass_time(3600.0) == "1:00:00.00" + + def test_sub_second_precision(self): + # 两位小数(厘秒精度) + result = _format_ass_time(1.234) + # 1.234 秒 = 0:00:01.23(ASS 格式是两位小数/厘秒) + assert result.startswith("0:00:01.") + # 检查秒部分是两位小数格式 + parts = result.split(":") + assert len(parts) == 3 + sec_part = parts[2] + assert "." in sec_part + decimals = sec_part.split(".")[1] + assert len(decimals) == 2 + + def test_negative_returns_zero_hours(self): + # 负数也应该能处理(虽然业务上不应该出现) + result = _format_ass_time(-1.0) + # 至少不崩溃 + assert isinstance(result, str) + assert ":" in result + + +class TestWrapText: + """按字数换行.""" + + def test_short_text_no_wrap(self): + result = _wrap_text("短文本", 10) + assert result == ["短文本"] + + def test_exact_length_no_wrap(self): + text = "一二三四五六七八九十" + result = _wrap_text(text, 10) + assert len(result) == 1 + assert result[0] == text + + def test_long_text_wraps(self): + text = "一二三四五六七八九十一二三四五六七八九十" + result = _wrap_text(text, 10) + assert len(result) == 2 + assert len(result[0]) == 10 + assert len(result[1]) == 10 + + def test_break_on_punctuation(self): + # 优先在标点处断开(标点在max_chars范围内靠前位置) + # 共12字,max=7,句号在第6位索引,range(7,3,-1)能扫到索引5的句号 + text = "一二三四五。六七八九十一" + result = _wrap_text(text, 7) + assert result[0] == "一二三四五。" + assert result[1] == "六七八九十一" + + def test_break_on_comma(self): + text = "一二三四五,六七八九十一" + result = _wrap_text(text, 7) + assert result[0] == "一二三四五," + assert result[1] == "六七八九十一" + + def test_multiple_lines(self): + text = "一二三四五六七八九十一二三四五六七八九十一二三四五" + result = _wrap_text(text, 10) + assert len(result) == 3 + assert len(result[0]) == 10 + assert len(result[1]) == 10 + assert len(result[2]) == 5 + + def test_empty_string(self): + result = _wrap_text("", 10) + assert result == [""] + + def test_max_chars_zero(self): + # 边界情况 + text = "abc" + result = _wrap_text(text, 0) + # 0的话,max_chars//2也是0,range不会执行 + # 按逻辑 len(text) > 0 成立,但 break_point 从 0 开始 + # 这取决于具体实现,只要不崩溃就行 + assert isinstance(result, list) + assert len(result) > 0 + + def test_punctuation_at_boundary(self): + # 标点刚好在 max_chars 位置 + text = "一二三四五六七八九。" + result = _wrap_text(text, 10) + assert len(result) == 1 # 刚好10个字符(含标点) + + +class TestSubtitleSegment: + """字幕片段数据类.""" + + def test_basic(self): + seg = SubtitleSegment(start=0.0, end=5.0, text="hello") + assert seg.start == 0.0 + assert seg.end == 5.0 + assert seg.text == "hello" + + def test_duration(self): + seg = SubtitleSegment(start=1.5, end=4.5, text="test") + assert seg.end - seg.start == 3.0 + + +class TestSubtitleStyle: + """字幕样式配置.""" + + def test_default_values(self): + style = SubtitleStyle() + assert style.font_size > 0 + assert isinstance(style.font_color, str) + assert isinstance(style.background_color, str) + + def test_ass_color_generation(self): + style = SubtitleStyle(font_color="#FFFFFF") + # 应该能生成 ASS 颜色格式 + color = style.ass_font_color + assert isinstance(color, str) + assert color.startswith("&H") + + def test_ass_background_color(self): + style = SubtitleStyle(background_color="#000000", background_opacity=0.5) + color = style.ass_background_color + assert isinstance(color, str) + assert color.startswith("&H") + + def test_opacity_affects_alpha(self): + style1 = SubtitleStyle(background_opacity=1.0) + style2 = SubtitleStyle(background_opacity=0.0) + # 不透明度不同,alpha 应该不同 + assert style1.ass_background_color != style2.ass_background_color diff --git a/tests/unit/test_tts_engine.py b/tests/unit/test_tts_engine.py new file mode 100644 index 000000000..8cac1d17b --- /dev/null +++ b/tests/unit/test_tts_engine.py @@ -0,0 +1,131 @@ +""" +TTS 配音引擎数据类与纯逻辑测试. + +覆盖 VoiceoverSegment / VoiceoverResult / TtsEngine 入口判断等纯逻辑. +TTS 合成调用依赖外部服务,由集成测试覆盖. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from video_processing.tts_engine import TtsEngine, VoiceoverResult, VoiceoverSegment + +from packages.domain.tts_config import TtsConfig + + +class TestVoiceoverSegment: + """配音片段数据类.""" + + def test_default_values(self): + seg = VoiceoverSegment(text="hello") + assert seg.text == "hello" + assert seg.start_time == 0.0 + assert seg.end_time == 0.0 + assert seg.audio_path is None + assert seg.duration == 0.0 + + def test_full_values(self): + seg = VoiceoverSegment( + text="hello world", + start_time=1.5, + end_time=3.0, + audio_path=Path("/tmp/test.wav"), + duration=1.5, + ) + assert seg.text == "hello world" + assert seg.start_time == 1.5 + assert seg.end_time == 3.0 + assert seg.audio_path == Path("/tmp/test.wav") + assert seg.duration == 1.5 + + def test_duration_calculation(self): + seg = VoiceoverSegment(text="test", start_time=0.0, end_time=5.5) + assert seg.end_time - seg.start_time == 5.5 + + +class TestVoiceoverResult: + """配音结果数据类.""" + + def test_default_failure(self): + result = VoiceoverResult() + assert result.success is False + assert result.segments == [] + assert result.total_duration == 0.0 + assert result.error_message == "" + + def test_success_result(self): + segs = [ + VoiceoverSegment(text="hello", duration=1.0), + VoiceoverSegment(text="world", duration=2.0), + ] + result = VoiceoverResult( + success=True, + segments=segs, + total_duration=3.0, + ) + assert result.success is True + assert len(result.segments) == 2 + assert result.total_duration == 3.0 + assert result.error_message == "" + + def test_failure_with_message(self): + result = VoiceoverResult(success=False, error_message="TTS服务不可用") + assert result.success is False + assert result.error_message == "TTS服务不可用" + + def test_segments_isolated_list(self): + """确保每个实例有独立的segments列表.""" + r1 = VoiceoverResult() + r2 = VoiceoverResult() + r1.segments.append(VoiceoverSegment(text="test")) + assert len(r2.segments) == 0 + + +class TestTtsEngineInit: + """TTS 引擎初始化.""" + + def test_init_creates_work_dir(self, tmp_path): + mock_tts = MagicMock() + work_dir = tmp_path / "tts_work" + engine = TtsEngine(mock_tts, work_dir) + assert work_dir.exists() + assert work_dir.is_dir() + + def test_init_with_existing_dir(self, tmp_path): + mock_tts = MagicMock() + work_dir = tmp_path / "existing" + work_dir.mkdir() + engine = TtsEngine(mock_tts, work_dir) + assert work_dir.exists() + + +class TestTtsEngineEntryConditions: + """TTS 引擎入口判断逻辑(不调用真实 TTS).""" + + def test_disabled_returns_failure(self, tmp_path): + mock_tts = MagicMock() + engine = TtsEngine(mock_tts, tmp_path) + config = TtsConfig(enabled=False, text="hello") + result = engine.generate_full_voiceover(config) + assert result.success is False + assert "未启用" in result.error_message or "空" in result.error_message + mock_tts.synthesize.assert_not_called() + + def test_empty_text_returns_failure(self, tmp_path): + mock_tts = MagicMock() + engine = TtsEngine(mock_tts, tmp_path) + config = TtsConfig(enabled=True, text="") + result = engine.generate_full_voiceover(config) + assert result.success is False + mock_tts.synthesize.assert_not_called() + + def test_whitespace_text_returns_failure(self, tmp_path): + mock_tts = MagicMock() + engine = TtsEngine(mock_tts, tmp_path) + config = TtsConfig(enabled=True, text=" ") + result = engine.generate_full_voiceover(config) + assert result.success is False + mock_tts.synthesize.assert_not_called() -- 2.54.0