From 7e88b01e98a10697de250b017c7d078babaff4f2 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 24 Jul 2026 21:00:01 +0800 Subject: [PATCH] =?UTF-8?q?test(P3-1):=20=E7=AC=AC57=E6=B3=A2=20worker?= =?UTF-8?q?=E5=B1=82=E5=B0=8F=E5=B7=A5=E5=85=B7+=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=8D=95=E6=B5=8B=EF=BC=88+60=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_render_subtitles.py: 35个(ASS颜色/对齐/样式构建/转义/时间格式化) - test_thumbnail_generator.py: 11个(时间格式化) - test_worker_settings.py: 14个(默认值/属性别名/类型) 3个模块的纯逻辑部分 --- tests/unit/test_render_subtitles.py | 216 +++++++++++++++++++++++++ tests/unit/test_thumbnail_generator.py | 61 +++++++ tests/unit/test_worker_settings.py | 83 ++++++++++ 3 files changed, 360 insertions(+) create mode 100755 tests/unit/test_render_subtitles.py create mode 100755 tests/unit/test_thumbnail_generator.py create mode 100755 tests/unit/test_worker_settings.py diff --git a/tests/unit/test_render_subtitles.py b/tests/unit/test_render_subtitles.py new file mode 100755 index 000000000..16cb5bc71 --- /dev/null +++ b/tests/unit/test_render_subtitles.py @@ -0,0 +1,216 @@ +""" +render_subtitles ASS 字幕纯函数测试. + +覆盖 _hex_to_ass_color / _position_to_ass_alignment / _build_ass_style / _escape_ass_text / _format_ass_time 等纯逻辑. +文件生成与 FFmpeg 渲染由集成测试覆盖. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from video_processing.render_subtitles import ( + _build_ass_style, + _escape_ass_text, + _format_ass_time, + _hex_to_ass_color, + _position_to_ass_alignment, +) + + +class TestHexToAssColor: + """HEX → ASS 颜色转换(不含 alpha 前缀版本).""" + + def test_white(self): + assert _hex_to_ass_color("#FFFFFF") == "&HFFFFFF" + + def test_black(self): + assert _hex_to_ass_color("#000000") == "&H000000" + + def test_red(self): + # #FF0000 → R=FF, G=00, B=00 → BGR=0000FF + assert _hex_to_ass_color("#FF0000") == "&H0000FF" + + def test_blue(self): + # #0000FF → R=00, G=00, B=FF → BGR=FF0000 + assert _hex_to_ass_color("#0000FF") == "&HFF0000" + + def test_green(self): + # #00FF00 → R=00, G=FF, B=00 → BGR=00FF00 + assert _hex_to_ass_color("#00FF00") == "&H00FF00" + + def test_without_hash(self): + assert _hex_to_ass_color("FF0000") == "&H0000FF" + + def test_lowercase(self): + assert _hex_to_ass_color("#ff0000") == "&H0000FF" + + def test_invalid_length_returns_default(self): + assert _hex_to_ass_color("#FFF") == "&H000000" # 3位 + assert _hex_to_ass_color("") == "&H000000" # 空 + + def test_mixed_case(self): + result = _hex_to_ass_color("#aBcDeF") + assert result == "&HEFCDAB" + + +class TestPositionToAssAlignment: + """位置 → ASS 对齐编号映射.""" + + def test_top(self): + assert _position_to_ass_alignment("top") == 8 + + def test_center(self): + assert _position_to_ass_alignment("center") == 5 + + def test_bottom(self): + assert _position_to_ass_alignment("bottom") == 2 + + def test_unknown_returns_top_default(self): + assert _position_to_ass_alignment("unknown") == 8 + assert _position_to_ass_alignment("") == 8 + assert _position_to_ass_alignment("left") == 8 + assert _position_to_ass_alignment(None) == 8 + + +class TestBuildAssStyle: + """构建 ASS Style 行.""" + + def test_basic_style(self): + style = _build_ass_style("Default") + assert style.startswith("Style: Default,") + assert "思源黑体" in style + assert "48" in style # font_size + + def test_custom_font(self): + style = _build_ass_style("Custom", font_name="Arial", font_size=32) + assert "Arial" in style + assert ",32," in style + + def test_bold(self): + style = _build_ass_style("Bold", bold=True) + assert ",-1," in style # bold = -1 (true) + + def test_not_bold(self): + style = _build_ass_style("Normal", bold=False) + parts = style.split(",") + # Bold 是第 8 个字段(index 7) + assert parts[7] == "0" + + def test_italic(self): + style = _build_ass_style("Italic", italic=True) + parts = style.split(",") + # Italic 是第 9 个字段(index 8) + assert parts[8] == "-1" + + def test_alignment(self): + style = _build_ass_style("Bottom", alignment=2) + parts = style.split(",") + # Alignment 是第 19 个字段(index 18) + assert parts[18] == "2" + + def test_margins(self): + style = _build_ass_style( + "Margins", + margin_v=80, + margin_l=60, + margin_r=60, + ) + parts = style.split(",") + # MarginL = parts[19], MarginR = parts[20], MarginV = parts[21] + assert parts[19] == "60" + assert parts[20] == "60" + assert parts[21] == "80" + + def test_outline_width(self): + style = _build_ass_style("Outline", outline_width=3.0) + parts = style.split(",") + # Outline 是第 17 个字段(index 16) + assert parts[16] == "3.0" + + def test_shadow_with_blur(self): + style = _build_ass_style( + "Shadow", + shadow_blur=1.0, + shadow_offset=(2, 3), + ) + parts = style.split(",") + # Shadow 是第 18 个字段(index 17) + assert parts[17] == "3" # shadow_offset[1] + + def test_shadow_without_blur(self): + style = _build_ass_style( + "NoShadow", + shadow_blur=0.0, + shadow_offset=(2, 3), + ) + parts = style.split(",") + assert parts[17] == "0" # 无模糊时阴影深度为0 + + def test_style_format_has_correct_field_count(self): + """ASS Style 行应该有 23 个字段.""" + style = _build_ass_style("Test") + parts = style.split(",") + assert len(parts) >= 22 # 至少22个字段(Format定义的) + + +class TestEscapeAssText: + """ASS 文本转义.""" + + def test_plain_text(self): + assert _escape_ass_text("hello") == "hello" + + def test_newline_unix(self): + assert _escape_ass_text("a\nb") == "a\\Nb" + + def test_newline_windows(self): + assert _escape_ass_text("a\r\nb") == "a\\Nb" + + def test_newline_mac(self): + assert _escape_ass_text("a\rb") == "a\\Nb" + + def test_curly_braces(self): + 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): + result = _escape_ass_text("line1\n{bold}\nline3") + assert "\\N" in result + assert "(bold)" in result + assert "{" not in result + + def test_empty(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(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) + parts = result.split(":") + sec_part = parts[2] + decimals = sec_part.split(".")[1] + assert len(decimals) == 2 # 两位小数(厘秒) diff --git a/tests/unit/test_thumbnail_generator.py b/tests/unit/test_thumbnail_generator.py new file mode 100755 index 000000000..a32b7f442 --- /dev/null +++ b/tests/unit/test_thumbnail_generator.py @@ -0,0 +1,61 @@ +""" +缩略图生成器纯函数测试. + +覆盖 _format_seek_time 等纯逻辑. +FFmpeg 抽帧与 OSS 上传由集成测试覆盖. +""" + +from __future__ import annotations + +import pytest + +from video_processing.thumbnail_generator import _format_seek_time + + +class TestFormatSeekTime: + """_format_seek_time 时间格式化.""" + + def test_zero(self): + assert _format_seek_time(0.0) == "00:00:00.00" + + def test_seconds_only(self): + assert _format_seek_time(5.5) == "00:00:05.50" + + def test_minutes(self): + assert _format_seek_time(65.25) == "00:01:05.25" + + def test_hours(self): + assert _format_seek_time(3661.5) == "01:01:01.50" + + def test_exact_minute(self): + assert _format_seek_time(60.0) == "00:01:00.00" + + def test_exact_hour(self): + assert _format_seek_time(3600.0) == "01:00:00.00" + + def test_very_short(self): + assert _format_seek_time(0.1) == "00:00:00.10" + + def test_long_video(self): + # 超过1小时 + assert _format_seek_time(7200.0) == "02:00:00.00" + + def test_sub_second_precision(self): + result = _format_seek_time(1.234) + 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_zero_padded_hours(self): + # 小时始终是2位 + result = _format_seek_time(5.0) + assert result.startswith("00:") + + def test_zero_padded_minutes(self): + # 分钟始终是2位 + result = _format_seek_time(5.0) + parts = result.split(":") + assert len(parts[1]) == 2 diff --git a/tests/unit/test_worker_settings.py b/tests/unit/test_worker_settings.py new file mode 100755 index 000000000..50e376645 --- /dev/null +++ b/tests/unit/test_worker_settings.py @@ -0,0 +1,83 @@ +""" +Worker 配置测试. + +覆盖 WorkerSettings 默认值、属性别名等纯逻辑. +环境变量加载由集成测试覆盖. +""" + +from __future__ import annotations + +import pytest + +from packages.config.worker_settings import WorkerSettings + + +class TestWorkerSettingsDefaults: + """WorkerSettings 默认值.""" + + def test_worker_name_default(self): + settings = WorkerSettings() + assert settings.worker_name == "xiaoxia-saas-worker" + + def test_worker_concurrency_default(self): + settings = WorkerSettings() + assert settings.worker_concurrency == 4 + + def test_worker_max_tasks_per_child_default(self): + settings = WorkerSettings() + assert settings.worker_max_tasks_per_child == 1000 + + def test_inherits_shared_settings(self): + """继承 SharedSettings 的字段.""" + settings = WorkerSettings() + # 验证至少有一些 SharedSettings 的字段存在 + assert hasattr(settings, "celery_broker_url") + assert hasattr(settings, "celery_result_backend") + + def test_custom_values(self): + settings = WorkerSettings( + worker_name="test-worker", + worker_concurrency=8, + worker_max_tasks_per_child=500, + ) + assert settings.worker_name == "test-worker" + assert settings.worker_concurrency == 8 + assert settings.worker_max_tasks_per_child == 500 + + +class TestWorkerSettingsAliases: + """Celery 字段名向后兼容别名.""" + + def test_broker_url_alias(self): + settings = WorkerSettings(celery_broker_url="redis://localhost:6379/0") + assert settings.broker_url == settings.celery_broker_url + assert settings.broker_url == "redis://localhost:6379/0" + + def test_result_backend_alias(self): + settings = WorkerSettings(celery_result_backend="redis://localhost:6379/1") + assert settings.result_backend == settings.celery_result_backend + assert settings.result_backend == "redis://localhost:6379/1" + + def test_broker_url_is_property(self): + """broker_url 是 property,每次读取都返回最新值.""" + settings = WorkerSettings() + # 验证是 property 描述符 + assert isinstance(type(settings).broker_url, property) + assert isinstance(type(settings).result_backend, property) + + +class TestWorkerSettingsType: + """类型验证.""" + + def test_worker_concurrency_is_int(self): + settings = WorkerSettings() + assert isinstance(settings.worker_concurrency, int) + + def test_worker_max_tasks_is_int(self): + settings = WorkerSettings() + assert isinstance(settings.worker_max_tasks_per_child, int) + + def test_worker_name_is_str(self): + settings = WorkerSettings() + assert isinstance(settings.worker_name, str) + assert len(settings.worker_name) > 0 -- 2.54.0