e86f137c3d
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 5s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 6s
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 23s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 20s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 27s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 43s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 42s
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m5s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m8s
CI/CD Pipeline / Integration Tests (push) Successful in 2m33s
CI/CD Pipeline / Validate - Style (push) Successful in 2m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m4s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m39s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m42s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 5m20s
CI/CD Pipeline / Validate - Security (push) Successful in 6m18s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m10s
AI Code Review / AI Code Review (pull_request) Successful in 6m21s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m59s
CI/CD Pipeline / Unit Tests (push) Failing after 8m16s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
311 lines
10 KiB
Python
311 lines
10 KiB
Python
"""字幕渲染纯函数测试 — _build_ass_style + generate_ass_subtitles."""
|
||
|
||
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,
|
||
generate_ass_subtitles,
|
||
)
|
||
|
||
|
||
class TestHexToAssColor:
|
||
"""_hex_to_ass_color 颜色转换测试."""
|
||
|
||
def test_white(self):
|
||
"""白色 #FFFFFF → &HFFFFFF (ASS BGR格式)."""
|
||
assert _hex_to_ass_color("#FFFFFF") == "&HFFFFFF"
|
||
|
||
def test_black(self):
|
||
"""黑色 #000000 → &H000000."""
|
||
assert _hex_to_ass_color("#000000") == "&H000000"
|
||
|
||
def test_red(self):
|
||
"""红色 #FF0000 → ASS是BGR顺序 → &H0000FF."""
|
||
assert _hex_to_ass_color("#FF0000") == "&H0000FF"
|
||
|
||
def test_blue(self):
|
||
"""蓝色 #0000FF → BGR → &HFF0000."""
|
||
assert _hex_to_ass_color("#0000FF") == "&HFF0000"
|
||
|
||
def test_green(self):
|
||
"""绿色 #00FF00 → BGR → &H00FF00."""
|
||
assert _hex_to_ass_color("#00FF00") == "&H00FF00"
|
||
|
||
def test_lowercase(self):
|
||
"""小写hex也支持."""
|
||
assert _hex_to_ass_color("#ff0000") == "&H0000FF"
|
||
|
||
def test_no_hash_prefix(self):
|
||
"""不带#的颜色."""
|
||
assert _hex_to_ass_color("FF0000") == "&H0000FF"
|
||
|
||
def test_invalid_length_fallback(self):
|
||
"""长度不对返回默认黑色."""
|
||
assert _hex_to_ass_color("#FFF") == "&H000000"
|
||
|
||
|
||
class TestPositionToAssAlignment:
|
||
"""_position_to_ass_alignment 位置映射测试."""
|
||
|
||
def test_top_center(self):
|
||
"""top → 上中(8)."""
|
||
assert _position_to_ass_alignment("top") == 8
|
||
|
||
def test_bottom_center(self):
|
||
"""bottom → 下中(2)."""
|
||
assert _position_to_ass_alignment("bottom") == 2
|
||
|
||
def test_center_middle(self):
|
||
"""center → 居中(5)."""
|
||
assert _position_to_ass_alignment("center") == 5
|
||
|
||
def test_unknown_defaults_to_bottom(self):
|
||
"""未知位置默认底部(2)."""
|
||
assert _position_to_ass_alignment("unknown") == 2
|
||
assert _position_to_ass_alignment("top_left") == 2
|
||
assert _position_to_ass_alignment("bottom_right") == 2
|
||
|
||
def test_empty_string_defaults_to_bottom(self):
|
||
"""空字符串默认底部."""
|
||
assert _position_to_ass_alignment("") == 2
|
||
|
||
|
||
class TestBuildAssStyle:
|
||
"""_build_ass_style ASS样式行构建测试."""
|
||
|
||
def test_basic_style_line(self):
|
||
"""基本样式行包含关键字段."""
|
||
line = _build_ass_style("Default")
|
||
assert line.startswith("Style: Default,")
|
||
assert "Noto Sans SC" in line
|
||
assert "65" in line # font_size 48*1.35=65
|
||
|
||
def test_bold_enabled(self):
|
||
"""加粗时Bold=-1."""
|
||
line = _build_ass_style("Bold", bold=True)
|
||
assert "Style: Bold," in line
|
||
# Bold字段位置:第7个逗号分隔字段=Bold=-1
|
||
parts = line.split(",")
|
||
# Name, Fontname, Fontsize, Primary, Secondary, Outline, Back, Bold, ...
|
||
assert parts[7] == "-1" # Bold
|
||
|
||
def test_bold_disabled(self):
|
||
"""不加粗时Bold=0."""
|
||
line = _build_ass_style("Normal", bold=False)
|
||
parts = line.split(",")
|
||
assert parts[7] == "0"
|
||
|
||
def test_italic_enabled(self):
|
||
"""斜体时Italic=-1."""
|
||
line = _build_ass_style("Italic", italic=True)
|
||
parts = line.split(",")
|
||
assert parts[8] == "-1" # Italic
|
||
|
||
def test_custom_font_size(self):
|
||
"""自定义字号."""
|
||
line = _build_ass_style("Big", font_size=72)
|
||
parts = line.split(",")
|
||
assert parts[2] == "97" # Fontsize 72*1.35=97
|
||
|
||
def test_custom_alignment(self):
|
||
"""自定义对齐方式."""
|
||
line = _build_ass_style("Bottom", alignment=2)
|
||
# Alignment是第16个字段(数一下)
|
||
# Name, Fontname, Fontsize, Primary, Secondary, Outline, Back, Bold, Italic, Underline, Strikeout, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, ...
|
||
parts = line.split(",")
|
||
assert parts[18] == "2" # Alignment (0-indexed: 18)
|
||
|
||
def test_outline_width(self):
|
||
"""描边宽度."""
|
||
line = _build_ass_style("Outline", outline_width=3.0)
|
||
parts = line.split(",")
|
||
assert parts[16] == "3.0" # Outline (BorderStyle后是Outline)
|
||
|
||
|
||
class TestEscapeAssText:
|
||
"""_escape_ass_text 文本转义测试."""
|
||
|
||
def test_plain_text_unchanged(self):
|
||
"""普通文本不变."""
|
||
assert _escape_ass_text("hello world") == "hello world"
|
||
|
||
def test_newline_converted(self):
|
||
"""换行符转成\\N."""
|
||
assert _escape_ass_text("line1\nline2") == "line1\\Nline2"
|
||
|
||
def test_crlf_converted(self):
|
||
"""CRLF转成\\N."""
|
||
assert _escape_ass_text("a\r\nb") == "a\\Nb"
|
||
|
||
def test_carriage_return_converted(self):
|
||
"""纯\\r转成\\N."""
|
||
assert _escape_ass_text("a\rb") == "a\\Nb"
|
||
|
||
def test_curly_braces_replaced(self):
|
||
"""大括号转成圆括号(防止ASS样式注入)."""
|
||
assert _escape_ass_text("{text}") == "(text)"
|
||
|
||
def test_mixed_special_chars(self):
|
||
"""混合特殊字符."""
|
||
result = _escape_ass_text("line1\nline2 {bold}\rlast")
|
||
assert "line1\\Nline2 (bold)\\Nlast" == result
|
||
|
||
|
||
class TestFormatAssTime:
|
||
"""_format_ass_time 时间格式化测试."""
|
||
|
||
def test_zero_seconds(self):
|
||
"""0秒."""
|
||
assert _format_ass_time(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(65.25) == "0:01:05.25"
|
||
|
||
def test_hours(self):
|
||
"""几小时."""
|
||
assert _format_ass_time(3661.5) == "1:01:01.50"
|
||
|
||
def test_always_two_decimal_places(self):
|
||
"""总是两位小数."""
|
||
assert _format_ass_time(1.0) == "0:00:01.00"
|
||
assert _format_ass_time(1.1) == "0:00:01.10"
|
||
|
||
|
||
class TestGenerateAssSubtitles:
|
||
"""generate_ass_subtitles ASS字幕文件生成测试."""
|
||
|
||
def test_no_subtitles_empty_file(self, tmp_path):
|
||
"""没有字幕生成空文件."""
|
||
output = tmp_path / "empty.ass"
|
||
result = generate_ass_subtitles(
|
||
output,
|
||
video_width=1080,
|
||
video_height=1920,
|
||
video_duration=10.0,
|
||
)
|
||
assert result == output
|
||
assert output.exists()
|
||
assert output.read_text(encoding="utf-8") == ""
|
||
|
||
def test_title_only(self, tmp_path):
|
||
"""只有标题."""
|
||
output = tmp_path / "title.ass"
|
||
result = generate_ass_subtitles(
|
||
output,
|
||
video_width=1080,
|
||
video_height=1920,
|
||
video_duration=30.0,
|
||
title_text="测试标题",
|
||
)
|
||
assert result == output
|
||
content = output.read_text(encoding="utf-8")
|
||
assert "Script Info" in content
|
||
assert "PlayResX: 1080" in content
|
||
assert "PlayResY: 1920" in content
|
||
assert "测试标题" in content
|
||
assert "V4+ Styles" in content
|
||
assert "Events" in content
|
||
|
||
def test_subtitle_only(self, tmp_path):
|
||
"""只有底部字幕."""
|
||
output = tmp_path / "sub.ass"
|
||
result = generate_ass_subtitles(
|
||
output,
|
||
video_width=1080,
|
||
video_height=1920,
|
||
video_duration=15.0,
|
||
subtitle_text="这是字幕",
|
||
)
|
||
assert result == output
|
||
content = output.read_text(encoding="utf-8")
|
||
assert "这是字幕" in content
|
||
assert "PlayResX: 1080" in content
|
||
|
||
def test_both_title_and_subtitle(self, tmp_path):
|
||
"""标题+字幕都有."""
|
||
output = tmp_path / "both.ass"
|
||
result = generate_ass_subtitles(
|
||
output,
|
||
video_width=720,
|
||
video_height=1280,
|
||
video_duration=20.0,
|
||
title_text="大标题",
|
||
subtitle_text="底部字幕",
|
||
)
|
||
content = output.read_text(encoding="utf-8")
|
||
assert "大标题" in content
|
||
assert "底部字幕" in content
|
||
# 应该有两种样式(title和subtitle)
|
||
assert content.count("Style:") >= 2
|
||
|
||
def test_title_disabled_by_config(self, tmp_path):
|
||
"""通过config禁用标题."""
|
||
output = tmp_path / "disabled_title.ass"
|
||
result = generate_ass_subtitles(
|
||
output,
|
||
video_width=1080,
|
||
video_height=1920,
|
||
video_duration=10.0,
|
||
title_text="标题",
|
||
title_config={"enabled": False},
|
||
subtitle_text="字幕",
|
||
)
|
||
content = output.read_text(encoding="utf-8")
|
||
assert "标题" not in content
|
||
assert "字幕" in content
|
||
|
||
def test_empty_title_text_not_rendered(self, tmp_path):
|
||
"""空标题文本不渲染."""
|
||
output = tmp_path / "empty_title.ass"
|
||
result = generate_ass_subtitles(
|
||
output,
|
||
video_width=1080,
|
||
video_height=1920,
|
||
video_duration=10.0,
|
||
title_text=" ",
|
||
subtitle_text="有字幕",
|
||
)
|
||
content = output.read_text(encoding="utf-8")
|
||
assert "有字幕" in content
|
||
|
||
def test_title_color_overlay(self, tmp_path):
|
||
"""自定义标题颜色."""
|
||
output = tmp_path / "color.ass"
|
||
result = generate_ass_subtitles(
|
||
output,
|
||
video_width=1080,
|
||
video_height=1920,
|
||
video_duration=10.0,
|
||
title_text="红色标题",
|
||
title_config={"color": "#FF0000"},
|
||
)
|
||
content = output.read_text(encoding="utf-8")
|
||
# 红色 → ASS BGR格式 &H0000FF
|
||
assert "&H0000FF" in content
|
||
|
||
def test_dialogue_line_format(self, tmp_path):
|
||
"""Dialogue行格式正确."""
|
||
output = tmp_path / "dialogue.ass"
|
||
generate_ass_subtitles(
|
||
output,
|
||
video_width=1080,
|
||
video_height=1920,
|
||
video_duration=5.0,
|
||
subtitle_text="测试字幕文本",
|
||
)
|
||
content = output.read_text(encoding="utf-8")
|
||
assert "Dialogue:" in content
|
||
assert "测试字幕文本" in content
|