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 old mode 100755 new mode 100644 index d95fe07ca..bdf7f7f04 --- a/tests/unit/test_sticker_engine.py +++ b/tests/unit/test_sticker_engine.py @@ -1,122 +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 TestImageStickerConfigDefaults: - """ImageStickerConfig 默认值测试.""" +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): - """默认值正确.""" - s = ImageStickerConfig() - assert s.enabled is False - assert s.type == "image" - assert s.position == "top_right" - assert s.x is None - assert s.y is None - assert s.x_unit == "percent" - assert s.y_unit == "percent" - assert s.scale == 1.0 - assert s.width is None - assert s.height is None - assert s.opacity == 1.0 - assert s.start_time == 0.0 - assert s.duration == 0.0 - assert s.fade_in == 0.0 - assert s.fade_out == 0.0 - assert s.z_index == 10 - assert s.image_url == "" - assert s.preset_id == "" + 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 TestTextStickerConfigDefaults: - """TextStickerConfig 默认值测试.""" +class TestTextStickerConfig: + """文字贴纸配置.""" def test_default_values(self): - """默认值正确.""" - s = TextStickerConfig() - assert s.enabled is False - assert s.type == "text" - assert s.text == "" - assert s.font_size == 36 - assert s.font_color == "#FFFFFF" - assert s.font_family == "sans" - assert s.stroke_color == "#000000" - assert s.stroke_width == 2 - assert s.shadow_color == "#000000" - assert s.shadow_x == 2 - assert s.shadow_y == 2 - assert s.shadow_alpha == 0.5 - assert s.position == "center" - assert s.start_time == 0.0 - assert s.duration == 0.0 - assert s.z_index == 10 - assert s.bg_color == "" - assert s.bg_padding == 8 - assert s.bg_alpha == 0.8 - assert s.bg_corner_radius == 8 + 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 测试.""" + """parse_stickers_from_config 便捷函数.""" def test_none_returns_empty(self): - """None返回空列表.""" - result = parse_stickers_from_config(None) - assert result == [] + assert parse_stickers_from_config(None) == [] def test_empty_dict_returns_empty(self): - """空dict返回空.""" - result = parse_stickers_from_config({}) - assert result == [] + assert parse_stickers_from_config({}) == [] def test_no_stickers_key_returns_empty(self): - """无stickers键返回空.""" - result = parse_stickers_from_config({"other": "value"}) - assert result == [] + assert parse_stickers_from_config({"other": "data"}) == [] - def test_stickers_not_list_returns_empty(self): - """stickers不是列表返回空.""" - result = parse_stickers_from_config({"stickers": "not_a_list"}) - assert result == [] + 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): - """空贴纸列表.""" - result = parse_stickers_from_config({"stickers": []}) - assert result == [] + assert parse_stickers_from_config({"stickers": []}) == [] - def test_single_sticker(self): - """单个贴纸.""" - result = parse_stickers_from_config( - { - "stickers": [{"type": "text", "text": "hello"}], - } - ) - assert len(result) == 1 - assert result[0]["text"] == "hello" - def test_multiple_stickers(self): - """多个贴纸.""" - result = parse_stickers_from_config( - { - "stickers": [ - {"type": "text", "text": "a"}, - {"type": "image", "image_url": "/b.png"}, - {"type": "text", "text": "c"}, - ], - } - ) - assert len(result) == 3 +class TestGetStickerCategories: + """get_sticker_categories 便捷函数.""" - def test_returns_raw_dicts(self): - """返回原始dict,不做转换.""" - sticker = {"type": "text", "text": "test", "font_size": 48} - result = parse_stickers_from_config({"stickers": [sticker]}) - assert result[0] is sticker # 引用相同,不做深拷贝 + 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 index 0ad5cff84..66efa4fdc 100755 --- a/tests/unit/test_subtitle_render_engine.py +++ b/tests/unit/test_subtitle_render_engine.py @@ -1,9 +1,15 @@ -"""字幕渲染引擎单元测试 - 工具函数+样式配置等纯逻辑.""" +""" +字幕渲染引擎纯函数与配置测试. + +覆盖 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, @@ -13,306 +19,276 @@ from video_processing.subtitle_render_engine import ( _wrap_text, ) -# ── 颜色转换测试 ────────────────────────────────────────────── - class TestHexToAssColor: - """_hex_to_ass_color 测试.""" + """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): - """红色 → BGR: 蓝绿红.""" + # #FF0000 → R=FF, G=00, B=00 → BGR=0000FF assert _hex_to_ass_color("#FF0000") == "&H000000FF" - def test_green(self): - """绿色.""" - assert _hex_to_ass_color("#00FF00") == "&H0000FF00" - def test_blue(self): - """蓝色.""" + # #0000FF → R=00, G=00, B=FF → BGR=FF0000 assert _hex_to_ass_color("#0000FF") == "&H00FF0000" - def test_no_hash_prefix(self): - """不带#号.""" + 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_invalid_length(self): - """长度不对返回默认白色.""" - assert _hex_to_ass_color("#FFF") == "&H00FFFFFF" - assert _hex_to_ass_color("#FF") == "&H00FFFFFF" - assert _hex_to_ass_color("") == "&H00FFFFFF" + def test_lowercase_hex(self): + assert _hex_to_ass_color("#ff0000") == "&H000000FF" - def test_lowercase_input(self): - """小写输入转为大写输出.""" - assert _hex_to_ass_color("#aabbcc") == "&H00CCBBAA" + 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_to_ass_bgr 测试.""" + """HEX → ASS BGR 部分.""" def test_white(self): - """白色BGR.""" assert _hex_to_ass_bgr("#FFFFFF") == "FFFFFF" - def test_red_bgr(self): - """红色 → BGR = 0000FF.""" + 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_bgr(self): - """蓝色 → BGR = FF0000.""" + def test_blue(self): + # #0000FF → BGR = FF0000 assert _hex_to_ass_bgr("#0000FF") == "FF0000" - def test_invalid_length(self): - """长度不对返回默认.""" - assert _hex_to_ass_bgr("#FF") == "FFFFFF" + 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: - """_opacity_to_ass_alpha 测试.""" + """不透明度 → ASS alpha.""" def test_fully_opaque(self): - """完全不透明 → 00.""" + # 1.0 → alpha = 255 - 255 = 0 → "00" assert _opacity_to_ass_alpha(1.0) == "00" def test_fully_transparent(self): - """完全透明 → FF.""" + # 0.0 → alpha = 255 - 0 = 255 → "FF" assert _opacity_to_ass_alpha(0.0) == "FF" def test_half(self): - """50% → 128 → 80.""" + # 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): - """75%不透明 → 64 → 40.""" + # 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: - """_escape_ass_text 转义测试.""" + """ASS 文本转义.""" - def test_normal_text_unchanged(self): - """普通文本不变.""" + def test_plain_text(self): assert _escape_ass_text("hello world") == "hello world" - def test_newline_converted(self): - """换行转成\\N.""" + def test_newline_unix(self): assert _escape_ass_text("line1\nline2") == "line1\\Nline2" - def test_crlf_converted(self): - """\\r\\n转成\\N.""" + def test_newline_windows(self): assert _escape_ass_text("line1\r\nline2") == "line1\\Nline2" - def test_carriage_return_converted(self): - """\\r转成\\N.""" + def test_newline_mac(self): assert _escape_ass_text("line1\rline2") == "line1\\Nline2" def test_curly_braces_replaced(self): - """花括号替换成圆括号(ASS控制符).""" + # 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 = "hello\n{world}\r\nend" + 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 "(world)" in result + assert "\n" not in result + assert "\r" not in result + + def test_empty_string(self): + assert _escape_ass_text("") == "" class TestFormatAssTime: - """_format_ass_time 时间格式化测试.""" + """秒 → ASS 时间格式.""" def test_zero(self): - """0秒.""" - assert _format_ass_time(0) == "0:00:00.00" + 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_and_seconds(self): - """分+秒.""" - assert _format_ass_time(125.5) == "0:02:05.50" + def test_minutes(self): + assert _format_ass_time(65.25) == "0:01:05.25" - def test_hours_minutes_seconds(self): - """时+分+秒.""" - assert _format_ass_time(3725.25) == "1:02:05.25" + def test_hours(self): + assert _format_ass_time(3661.5) == "1:01:01.50" - def test_exactly_one_hour(self): - """刚好1小时.""" + 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_single_digit_minute(self): - """分钟补零.""" - result = _format_ass_time(65.0) + 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 parts[1] == "01" + assert len(parts) == 3 + sec_part = parts[2] + assert "." in sec_part + decimals = sec_part.split(".")[1] + assert len(decimals) == 2 - def test_always_two_decimal_places(self): - """总是两位小数.""" - result = _format_ass_time(3.0) - assert result.endswith(".00") + def test_negative_returns_zero_hours(self): + # 负数也应该能处理(虽然业务上不应该出现) + result = _format_ass_time(-1.0) + # 至少不崩溃 + assert isinstance(result, str) + assert ":" in result class TestWrapText: - """_wrap_text 换行测试.""" + """按字数换行.""" def test_short_text_no_wrap(self): - """短文本不换行.""" - result = _wrap_text("hello", 10) - assert len(result) == 1 - assert result[0] == "hello" + result = _wrap_text("短文本", 10) + assert result == ["短文本"] def test_exact_length_no_wrap(self): - """刚好长度不换行.""" - text = "abcdefghij" # 10 chars + text = "一二三四五六七八九十" result = _wrap_text(text, 10) assert len(result) == 1 assert result[0] == text - def test_simple_wrap(self): - """简单换行.""" - text = "abcdefghijklmnopqrst" # 20 chars + 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_uneven_wrap(self): - """不均等换行.""" - text = "abcdefghijklm" # 13 chars - result = _wrap_text(text, 5) + 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 result[0] == "abcde" - assert result[1] == "fghij" - assert result[2] == "klm" + assert len(result[0]) == 10 + assert len(result[1]) == 10 + assert len(result[2]) == 5 - def test_chinese_text_wrap(self): - """中文文本换行(按字符数).""" - text = "一二三四五六七八九十" - result = _wrap_text(text, 5) - assert len(result) == 2 - assert result[0] == "一二三四五" - assert result[1] == "六七八九十" + def test_empty_string(self): + result = _wrap_text("", 10) + assert result == [""] + + def test_max_chars_one(self): + # max_chars=1 时每个字符一行 + text = "abc" + result = _wrap_text(text, 1) + assert result == ["a", "b", "c"] + + def test_punctuation_at_boundary(self): + # 标点刚好在 max_chars 位置 + text = "一二三四五六七八九。" + result = _wrap_text(text, 10) + assert len(result) == 1 # 刚好10个字符(含标点) -# ── SubtitleStyle 测试 ──────────────────────────────────── +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 TestSubtitleStyleDefaults: - """SubtitleStyle 默认值测试.""" +class TestSubtitleStyle: + """字幕样式配置.""" def test_default_values(self): - """默认值正确.""" style = SubtitleStyle() assert style.font_size > 0 - assert style.bold is False - assert style.italic is False - assert style.stroke_enabled is True - assert style.shadow_enabled is False - assert style.background_enabled is False - assert style.fade_in == 0.0 - assert style.fade_out == 0.0 - assert style.animation_type == "none" + 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") -class TestSubtitleStyleFromDict: - """SubtitleStyle.from_dict 测试.""" + 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_none_returns_default(self): - """None返回默认样式.""" - style = SubtitleStyle.from_dict(None) - assert isinstance(style, SubtitleStyle) - - def test_empty_dict_returns_default(self): - """空dict返回默认.""" - style = SubtitleStyle.from_dict({}) - assert isinstance(style, SubtitleStyle) - - def test_custom_font_size(self): - """自定义字号.""" - style = SubtitleStyle.from_dict({"size": 48}) - assert style.font_size == 48 - - def test_custom_color(self): - """自定义颜色.""" - style = SubtitleStyle.from_dict({"color": "#FF0000"}) - assert style.font_color == "#FF0000" - - def test_bold_enabled(self): - """启用粗体.""" - style = SubtitleStyle.from_dict({"bold": True}) - assert style.bold is True - - def test_stroke_disabled(self): - """禁用描边.""" - style = SubtitleStyle.from_dict({"stroke_enabled": False}) - assert style.stroke_enabled is False - - def test_background_enabled(self): - """启用背景框.""" - style = SubtitleStyle.from_dict({"background_enabled": True}) - assert style.background_enabled is True - - def test_background_opacity_clamped(self): - """背景透明度钳制.""" - style = SubtitleStyle.from_dict( - { - "background_enabled": True, - "background_opacity": 2.0, - } - ) - assert style.background_opacity == 1.0 - - def test_invalid_position_falls_back(self): - """无效位置回退到默认.""" - style = SubtitleStyle.from_dict({"position": "invalid_pos"}) - # 回退到默认位置 - assert style.position is not None - - def test_fade_in_non_negative(self): - """淡入时长不能为负.""" - style = SubtitleStyle.from_dict({"fade_in": -1.0}) - assert style.fade_in == 0.0 - - def test_custom_animation(self): - """自定义动画.""" - style = SubtitleStyle.from_dict({"animation_type": "fade"}) - assert style.animation_type == "fade" - - -class TestSubtitleStyleProperties: - """SubtitleStyle 属性测试.""" - - def test_ass_font_color_format(self): - """ass_font_color格式正确.""" - style = SubtitleStyle(font_color="#FF0000") - result = style.ass_font_color - assert result.startswith("&H") - assert len(result) == 10 # &H + AABBGGRR = 10 chars - - def test_ass_background_color_format(self): - """背景颜色格式正确.""" - style = SubtitleStyle( - background_enabled=True, - background_color="#000000", - background_opacity=0.5, - ) - result = style.ass_background_color - assert result.startswith("&H") - - def test_alignment_is_int(self): - """alignment是整数.""" - style = SubtitleStyle() - assert isinstance(style.alignment, int) + 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()