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
625 lines
20 KiB
Python
625 lines
20 KiB
Python
"""ass_subtitle_builder 单元测试 - wave169
|
||
|
||
覆盖:
|
||
- hex_to_ass_color 颜色转换
|
||
- position_to_ass_alignment 位置对齐映射
|
||
- build_ass_style Style行构建
|
||
- escape_ass_text 文本转义
|
||
- format_ass_time 时间格式化
|
||
- build_ass_content 完整ASS内容生成
|
||
"""
|
||
|
||
import pytest
|
||
|
||
from packages.domain.ass_subtitle_builder import (
|
||
TITLE_MARGIN_BOTTOM,
|
||
TITLE_MARGIN_SIDE,
|
||
TITLE_MARGIN_TOP,
|
||
_wrap_title_text,
|
||
build_ass_content,
|
||
build_ass_style,
|
||
escape_ass_text,
|
||
format_ass_time,
|
||
hex_to_ass_color,
|
||
position_to_ass_alignment,
|
||
)
|
||
|
||
# ============================================================
|
||
# hex_to_ass_color
|
||
# ============================================================
|
||
|
||
|
||
class TestHexToAssColor:
|
||
def test_red(self):
|
||
# #FF0000 → &H0000FF (BBGGRR)
|
||
assert hex_to_ass_color("#FF0000") == "&H0000FF"
|
||
|
||
def test_blue(self):
|
||
# #0000FF → &HFF0000
|
||
assert hex_to_ass_color("#0000FF") == "&HFF0000"
|
||
|
||
def test_green(self):
|
||
# #00FF00 → &H00FF00
|
||
assert hex_to_ass_color("#00FF00") == "&H00FF00"
|
||
|
||
def test_white(self):
|
||
assert hex_to_ass_color("#FFFFFF") == "&HFFFFFF"
|
||
|
||
def test_black(self):
|
||
assert hex_to_ass_color("#000000") == "&H000000"
|
||
|
||
def test_without_hash_prefix(self):
|
||
assert hex_to_ass_color("FF0000") == "&H0000FF"
|
||
|
||
def test_mixed_case(self):
|
||
assert hex_to_ass_color("#aBcDeF") == "&HEFCDAB"
|
||
|
||
def test_invalid_length_short(self):
|
||
assert hex_to_ass_color("#FFF") == "&H000000"
|
||
|
||
def test_invalid_length_long(self):
|
||
assert hex_to_ass_color("#FF0000FF") == "&H000000"
|
||
|
||
def test_empty_string(self):
|
||
assert hex_to_ass_color("") == "&H000000"
|
||
|
||
def test_uppercase_output(self):
|
||
result = hex_to_ass_color("#abcdef")
|
||
assert result == result.upper()
|
||
|
||
|
||
# ============================================================
|
||
# position_to_ass_alignment
|
||
# ============================================================
|
||
|
||
|
||
class TestPositionToAssAlignment:
|
||
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_defaults_bottom(self):
|
||
assert position_to_ass_alignment("unknown") == 2
|
||
|
||
def test_empty_defaults_bottom(self):
|
||
assert position_to_ass_alignment("") == 2
|
||
|
||
|
||
# ============================================================
|
||
# build_ass_style
|
||
# ============================================================
|
||
|
||
|
||
class TestBuildAssStyle:
|
||
def test_minimal_style(self):
|
||
result = build_ass_style("Default")
|
||
assert result.startswith("Style: Default,")
|
||
|
||
def test_contains_font_name(self):
|
||
result = build_ass_style("S1", font_name="Arial")
|
||
assert "Arial" in result
|
||
|
||
def test_contains_font_size(self):
|
||
result = build_ass_style("S1", font_size=36)
|
||
# Style行格式:Name, Fontname, Fontsize, ...(36*1.35=48.6→49)
|
||
parts = result.split(",")
|
||
assert parts[2] == "49"
|
||
|
||
def test_bold_true(self):
|
||
result = build_ass_style("S1", bold=True)
|
||
parts = result.split(",")
|
||
# Bold 是第7个字段(索引7)
|
||
assert parts[7] == "-1"
|
||
|
||
def test_bold_false(self):
|
||
result = build_ass_style("S1", bold=False)
|
||
parts = result.split(",")
|
||
assert parts[7] == "0"
|
||
|
||
def test_italic_true(self):
|
||
result = build_ass_style("S1", italic=True)
|
||
parts = result.split(",")
|
||
# Italic 是第8个字段(索引8)
|
||
assert parts[8] == "-1"
|
||
|
||
def test_italic_false(self):
|
||
result = build_ass_style("S1", italic=False)
|
||
parts = result.split(",")
|
||
assert parts[8] == "0"
|
||
|
||
def test_alignment(self):
|
||
result = build_ass_style("S1", alignment=5)
|
||
parts = result.split(",")
|
||
# Alignment 是第18个字段(索引18)
|
||
assert parts[18] == "5"
|
||
|
||
def test_outline_width(self):
|
||
result = build_ass_style("S1", outline_width=3.0)
|
||
parts = result.split(",")
|
||
# Outline 是第16个字段(索引16)
|
||
assert parts[16] == "3.0"
|
||
|
||
def test_margins(self):
|
||
result = build_ass_style("S1", margin_l=10, margin_r=20, margin_v=30)
|
||
parts = result.split(",")
|
||
assert parts[19] == "10" # MarginL
|
||
assert parts[20] == "20" # MarginR
|
||
assert parts[21] == "30" # MarginV
|
||
|
||
def test_shadow_with_blur(self):
|
||
result = build_ass_style("S1", shadow_blur=2.0, shadow_offset=(3, 5))
|
||
parts = result.split(",")
|
||
# Shadow 深度 = shadow_offset[1] when blur > 0
|
||
assert parts[17] == "5"
|
||
|
||
def test_shadow_without_blur(self):
|
||
result = build_ass_style("S1", shadow_blur=0.0, shadow_offset=(3, 5))
|
||
parts = result.split(",")
|
||
assert parts[17] == "0"
|
||
|
||
def test_primary_color(self):
|
||
result = build_ass_style("S1", primary_color="&H00FFFFFF")
|
||
parts = result.split(",")
|
||
assert parts[3] == "&H00FFFFFF"
|
||
|
||
def test_outline_color(self):
|
||
result = build_ass_style("S1", outline_color="&H000000FF")
|
||
parts = result.split(",")
|
||
assert parts[5] == "&H000000FF"
|
||
|
||
def test_22_fields(self):
|
||
# ASS Style 行应有23个字段(Style: 前缀 + 22个逗号分隔字段)
|
||
result = build_ass_style("Default")
|
||
parts = result.split(",")
|
||
assert len(parts) >= 22 # 至少22个字段
|
||
|
||
|
||
# ============================================================
|
||
# escape_ass_text
|
||
# ============================================================
|
||
|
||
|
||
class TestEscapeAssText:
|
||
def test_plain_text_unchanged(self):
|
||
assert escape_ass_text("Hello World") == "Hello World"
|
||
|
||
def test_newline_converted(self):
|
||
assert escape_ass_text("line1\nline2") == "line1\\Nline2"
|
||
|
||
def test_crlf_converted(self):
|
||
assert escape_ass_text("line1\r\nline2") == "line1\\Nline2"
|
||
|
||
def test_carriage_return_converted(self):
|
||
assert escape_ass_text("line1\rline2") == "line1\\Nline2"
|
||
|
||
def test_curly_braces_escaped(self):
|
||
assert escape_ass_text("{text}") == "(text)"
|
||
|
||
def test_opening_brace_escaped(self):
|
||
assert escape_ass_text("{hello") == "(hello"
|
||
|
||
def test_closing_brace_escaped(self):
|
||
assert escape_ass_text("hello}") == "hello)"
|
||
|
||
def test_multiple_braces(self):
|
||
assert escape_ass_text("{a}{b}") == "(a)(b)"
|
||
|
||
def test_mixed_newlines_and_braces(self):
|
||
result = escape_ass_text("line1\n{tag}line2")
|
||
assert result == "line1\\N(tag)line2"
|
||
|
||
def test_empty_string(self):
|
||
assert escape_ass_text("") == ""
|
||
|
||
def test_chinese_text(self):
|
||
assert escape_ass_text("你好世界") == "你好世界"
|
||
|
||
def test_slash_converted_to_newline(self):
|
||
"""半角斜杠 / 应转为 ASS 硬换行。"""
|
||
assert escape_ass_text("标题一/标题二") == "标题一\\N标题二"
|
||
|
||
def test_fullwidth_slash_converted_to_newline(self):
|
||
"""全角斜杠 / 应转为 ASS 硬换行。"""
|
||
assert escape_ass_text("标题一/标题二") == "标题一\\N标题二"
|
||
|
||
def test_backslash_n_in_input(self):
|
||
# 文本里本身有 \n 字符串(不是换行符)
|
||
result = escape_ass_text("\\n")
|
||
assert result == "\\n" # 不变,因为不是实际换行符
|
||
|
||
|
||
# ============================================================
|
||
# format_ass_time
|
||
# ============================================================
|
||
|
||
|
||
class TestFormatAssTime:
|
||
def test_zero(self):
|
||
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(self):
|
||
assert format_ass_time(90.0) == "0:01:30.00"
|
||
|
||
def test_hours(self):
|
||
assert format_ass_time(3661.5) == "1:01:01.50"
|
||
|
||
def test_multi_hours(self):
|
||
assert format_ass_time(7384.25) == "2:03:04.25"
|
||
|
||
def test_two_decimal_places(self):
|
||
result = format_ass_time(1.234)
|
||
# 两位小数
|
||
assert result.endswith(".23") or result.endswith(".24")
|
||
|
||
def test_minutes_two_digits(self):
|
||
result = format_ass_time(65.0)
|
||
parts = result.split(":")
|
||
assert len(parts[1]) == 2
|
||
assert parts[1] == "01"
|
||
|
||
def test_seconds_two_digits_before_decimal(self):
|
||
result = format_ass_time(5.0)
|
||
parts = result.split(":")
|
||
sec_part = parts[2]
|
||
assert sec_part.startswith("05")
|
||
|
||
def test_float_input(self):
|
||
assert format_ass_time(123.45) == "0:02:03.45"
|
||
|
||
def test_exactly_one_hour(self):
|
||
assert format_ass_time(3600.0) == "1:00:00.00"
|
||
|
||
|
||
# ============================================================
|
||
# build_ass_content
|
||
# ============================================================
|
||
|
||
|
||
class TestBuildAssContent:
|
||
def test_no_subtitles_returns_empty(self):
|
||
result = build_ass_content(video_width=1920, video_height=1080, video_duration=10.0)
|
||
assert result == ""
|
||
|
||
def test_title_only(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=5.0,
|
||
title_text="Test Title",
|
||
)
|
||
assert result != ""
|
||
assert "[Script Info]" in result
|
||
assert "PlayResX: 1920" in result
|
||
assert "PlayResY: 1080" in result
|
||
assert "[V4+ Styles]" in result
|
||
assert "[Events]" in result
|
||
assert "TitleStyle" in result
|
||
assert "Test Title" in result
|
||
|
||
def test_subtitle_only(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=5.0,
|
||
subtitle_text="Hello Subtitle",
|
||
)
|
||
assert result != ""
|
||
assert "SubtitleStyle" in result
|
||
assert "Hello Subtitle" in result
|
||
|
||
def test_both_title_and_subtitle(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=5.0,
|
||
title_text="Title",
|
||
subtitle_text="Subtitle",
|
||
)
|
||
assert "TitleStyle" in result
|
||
assert "SubtitleStyle" in result
|
||
assert "Title" in result
|
||
assert "Subtitle" in result
|
||
|
||
def test_whitespace_title_returns_empty(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=5.0,
|
||
title_text=" ",
|
||
)
|
||
assert result == ""
|
||
|
||
def test_whitespace_subtitle_returns_empty(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=5.0,
|
||
subtitle_text=" \n ",
|
||
)
|
||
assert result == ""
|
||
|
||
def test_title_disabled(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=5.0,
|
||
title_text="Title",
|
||
title_config={"enabled": False},
|
||
)
|
||
assert result == ""
|
||
|
||
def test_subtitle_disabled(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=5.0,
|
||
subtitle_text="Sub",
|
||
subtitle_config={"enabled": False},
|
||
)
|
||
assert result == ""
|
||
|
||
def test_title_color(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=5.0,
|
||
title_text="T",
|
||
title_config={"color": "#FF0000"},
|
||
)
|
||
# 红色 → &H0000FF
|
||
assert "&H0000FF" in result
|
||
|
||
def test_title_position_top(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=5.0,
|
||
title_text="T",
|
||
title_config={"position": "top"},
|
||
)
|
||
# top alignment = 8
|
||
assert "TitleStyle" in result
|
||
|
||
def test_title_position_bottom(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=5.0,
|
||
title_text="T",
|
||
title_config={"position": "bottom"},
|
||
)
|
||
# bottom=2, 检查Style行里有2
|
||
assert "TitleStyle" in result
|
||
|
||
def test_subtitle_position_bottom(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=5.0,
|
||
subtitle_text="S",
|
||
subtitle_config={"position": "bottom"},
|
||
)
|
||
assert "SubtitleStyle" in result
|
||
|
||
def test_title_font_size(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=5.0,
|
||
title_text="T",
|
||
title_config={"size": 72},
|
||
)
|
||
# 在TitleStyle行里查找字体大小(字号上限已移除,72应原样保留)
|
||
for line in result.split("\n"):
|
||
if line.startswith("Style: TitleStyle"):
|
||
parts = line.split(",")
|
||
assert parts[2] == "97" # 72*1.35=97.2→97
|
||
break
|
||
|
||
def test_title_font_size_frontend_field_alias(self):
|
||
"""前端传 font_size 应归一化为内部 size 字段。"""
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=5.0,
|
||
title_text="T",
|
||
title_config={"font_size": 48},
|
||
)
|
||
for line in result.split("\n"):
|
||
if line.startswith("Style: TitleStyle"):
|
||
parts = line.split(",")
|
||
assert parts[2] == "65" # 48*1.35=64.8→65
|
||
break
|
||
|
||
def test_title_font_color_frontend_field_alias(self):
|
||
"""前端传 font_color 应归一化为内部 color 字段。"""
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=5.0,
|
||
title_text="T",
|
||
title_config={"font_color": "#FF0000"},
|
||
)
|
||
# 红色 → &H0000FF
|
||
assert "&H0000FF" in result
|
||
|
||
def test_title_size_takes_precedence_over_font_size(self):
|
||
"""同时传 size 和 font_size 时,size 优先。"""
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=5.0,
|
||
title_text="T",
|
||
title_config={"size": 56, "font_size": 28},
|
||
)
|
||
for line in result.split("\n"):
|
||
if line.startswith("Style: TitleStyle"):
|
||
parts = line.split(",")
|
||
assert parts[2] == "76" # 56*1.35=75.6→76
|
||
break
|
||
|
||
def test_title_bold(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=5.0,
|
||
title_text="T",
|
||
title_config={"bold": True},
|
||
)
|
||
for line in result.split("\n"):
|
||
if line.startswith("Style: TitleStyle"):
|
||
parts = line.split(",")
|
||
assert parts[7] == "-1"
|
||
break
|
||
|
||
def test_title_stroke_enabled(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=5.0,
|
||
title_text="T",
|
||
title_config={"stroke": {"enabled": True, "width": 3, "color": "#000000"}},
|
||
)
|
||
for line in result.split("\n"):
|
||
if line.startswith("Style: TitleStyle"):
|
||
parts = line.split(",")
|
||
assert parts[16] == "3.0"
|
||
break
|
||
|
||
def test_title_stroke_disabled(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=5.0,
|
||
title_text="T",
|
||
title_config={"stroke": {"enabled": False, "width": 3}},
|
||
)
|
||
for line in result.split("\n"):
|
||
if line.startswith("Style: TitleStyle"):
|
||
parts = line.split(",")
|
||
assert parts[16] == "0.0"
|
||
break
|
||
|
||
def test_title_shadow_enabled(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=5.0,
|
||
title_text="T",
|
||
title_config={"shadow": {"enabled": True, "blur": 2, "offset_x": 2, "offset_y": 4}},
|
||
)
|
||
for line in result.split("\n"):
|
||
if line.startswith("Style: TitleStyle"):
|
||
parts = line.split(",")
|
||
assert parts[17] == "4" # Shadow = offset_y
|
||
break
|
||
|
||
def test_dialogue_has_correct_timing(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=65.5,
|
||
title_text="T",
|
||
)
|
||
# 结束时间应该是 0:01:05.50
|
||
assert "0:01:05.50" in result
|
||
|
||
def test_dialogue_starts_at_zero(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=10.0,
|
||
subtitle_text="S",
|
||
)
|
||
assert "0:00:00.00" in result
|
||
|
||
def test_contains_script_info_header(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=10.0,
|
||
title_text="T",
|
||
)
|
||
assert "[Script Info]" in result
|
||
assert "ScriptType: v4.00+" in result
|
||
assert "ScaledBorderAndShadow: yes" in result
|
||
|
||
def test_escaped_text_in_dialogue(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=10.0,
|
||
title_text="line1\nline2",
|
||
)
|
||
# 换行符应被转义为 \N
|
||
assert "\\N" in result
|
||
assert "line1" in result
|
||
assert "line2" in result
|
||
|
||
|
||
# ============================================================
|
||
# 常量验证
|
||
# ============================================================
|
||
|
||
|
||
class TestConstants:
|
||
def test_margin_values(self):
|
||
assert TITLE_MARGIN_TOP > 0
|
||
assert TITLE_MARGIN_BOTTOM > 0
|
||
assert TITLE_MARGIN_SIDE > 0
|
||
|
||
def test_margins_are_integers(self):
|
||
assert isinstance(TITLE_MARGIN_TOP, int)
|
||
assert isinstance(TITLE_MARGIN_BOTTOM, int)
|
||
assert isinstance(TITLE_MARGIN_SIDE, int)
|
||
|
||
|
||
# ============================================================
|
||
# _wrap_title_text 换行逻辑验证
|
||
# ============================================================
|
||
|
||
|
||
class TestWrapTitleTextFontSizeConsistency:
|
||
"""验证换行计算使用原始 font_size,与 CSS 预览一致。"""
|
||
|
||
def test_font_size_113_uses_original_not_compensated(self):
|
||
"""font_size=113 时,每行应容纳8个字(113px字宽),而非6个字(153px字宽)。"""
|
||
# 标题"永康拾掇脚阔头"共7个字
|
||
# 可用宽度 = 1080 - 60 - 60 = 960px
|
||
# 用 font_size=113:char_width=113,960/113 ≈ 8.5,每行8个字
|
||
# 7个字 < 8个字,应该在一行内
|
||
title = "永康拾掇脚阔头"
|
||
result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60)
|
||
# 不应该有换行
|
||
assert "\\N" not in result
|
||
assert result == title
|
||
|
||
def test_long_title_wraps_correctly(self):
|
||
"""长标题应该按 font_size 字宽正确换行。"""
|
||
# 16个中文字,每行8个字,应该换行为2行
|
||
title = "永康拾掇脚阔头来一个笑一个哈哈哈"
|
||
result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60)
|
||
# 应该有一个换行
|
||
assert result.count("\\N") == 1
|
||
# 每行8个字
|
||
lines = result.split("\\N")
|
||
assert len(lines) == 2
|
||
assert len(lines[0]) == 8
|
||
assert len(lines[1]) == 8
|
||
|
||
def test_mixed_cjk_and_ascii(self):
|
||
"""混合中英文时,英文按半角宽度计算。"""
|
||
# "测试test" = 2个中文(2*113=226) + 4个英文(4*113*0.55=248.6) = 474.6px
|
||
title = "测试test"
|
||
result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60)
|
||
# 总宽度474.6px < 960px,应该在一行内
|
||
assert "\\N" not in result
|