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
792 lines
29 KiB
Python
Executable File
792 lines
29 KiB
Python
Executable File
"""ASS 字幕构建领域模型单元测试 — 纯逻辑,无文件IO."""
|
||
|
||
from __future__ import annotations
|
||
|
||
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,
|
||
)
|
||
|
||
# ── 颜色转换 ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestHexToAssColor:
|
||
def test_red(self):
|
||
assert hex_to_ass_color("#FF0000") == "&H0000FF"
|
||
|
||
def test_green(self):
|
||
assert hex_to_ass_color("#00FF00") == "&H00FF00"
|
||
|
||
def test_blue(self):
|
||
assert hex_to_ass_color("#0000FF") == "&HFF0000"
|
||
|
||
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(self):
|
||
assert hex_to_ass_color("FF0000") == "&H0000FF"
|
||
|
||
def test_lowercase(self):
|
||
assert hex_to_ass_color("#ff0000") == "&H0000FF"
|
||
|
||
def test_invalid_length_short(self):
|
||
assert hex_to_ass_color("#FFF") == "&H000000"
|
||
|
||
def test_invalid_length_long(self):
|
||
assert hex_to_ass_color("#FFFFFFFF") == "&H000000"
|
||
|
||
def test_empty(self):
|
||
assert hex_to_ass_color("") == "&H000000"
|
||
|
||
|
||
# ── 位置对齐 ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
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_default_bottom(self):
|
||
assert position_to_ass_alignment("unknown") == 2
|
||
|
||
def test_empty_default_bottom(self):
|
||
assert position_to_ass_alignment("") == 2
|
||
|
||
|
||
# ── Style 行构建 ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestBuildAssStyle:
|
||
def test_minimal_style(self):
|
||
result = build_ass_style("TestStyle")
|
||
assert result.startswith("Style: TestStyle,")
|
||
assert "Noto Sans SC" in result
|
||
assert ",65," in result # 48*1.35=64.8→65
|
||
|
||
def test_custom_font_size(self):
|
||
result = build_ass_style("Title", font_size=64)
|
||
assert ",86," in result # 64*1.35=86.4→86
|
||
|
||
def test_bold_enabled(self):
|
||
result = build_ass_style("BoldStyle", bold=True)
|
||
parts = result.split(",")
|
||
# Bold 是第 8 个字段(index 7)
|
||
assert parts[7] == "-1"
|
||
|
||
def test_bold_disabled(self):
|
||
result = build_ass_style("NormalStyle", bold=False)
|
||
parts = result.split(",")
|
||
assert parts[7] == "0"
|
||
|
||
def test_italic_enabled(self):
|
||
result = build_ass_style("ItalicStyle", italic=True)
|
||
parts = result.split(",")
|
||
assert parts[8] == "-1"
|
||
|
||
def test_alignment(self):
|
||
result = build_ass_style("AlignBottom", alignment=2)
|
||
parts = result.split(",")
|
||
# Alignment 是第 19 个字段(index 18)
|
||
assert parts[18] == "2"
|
||
|
||
def test_margins(self):
|
||
result = build_ass_style("MarginStyle", margin_v=100, margin_l=50, margin_r=50)
|
||
parts = result.split(",")
|
||
# MarginL, MarginR, MarginV 分别是 index 19, 20, 21
|
||
assert parts[19] == "50"
|
||
assert parts[20] == "50"
|
||
assert parts[21] == "100"
|
||
|
||
def test_outline_width(self):
|
||
result = build_ass_style("OutlineStyle", outline_width=3.5)
|
||
# Outline 是 index 16
|
||
parts = result.split(",")
|
||
assert parts[16] == "3.5"
|
||
|
||
def test_shadow_with_blur(self):
|
||
result = build_ass_style("ShadowStyle", shadow_blur=4.0, shadow_offset=(2, 3))
|
||
parts = result.split(",")
|
||
# Shadow 深度(纵向偏移)是 index 17
|
||
assert parts[17] == "3"
|
||
|
||
def test_shadow_without_blur(self):
|
||
result = build_ass_style("NoShadowStyle", shadow_blur=0.0, shadow_offset=(2, 3))
|
||
parts = result.split(",")
|
||
assert parts[17] == "0"
|
||
|
||
def test_primary_color(self):
|
||
result = build_ass_style("ColorStyle", primary_color="&H00FFFFFF")
|
||
# PrimaryColour 是 index 3
|
||
parts = result.split(",")
|
||
assert parts[3] == "&H00FFFFFF"
|
||
|
||
def test_outline_color(self):
|
||
result = build_ass_style("StrokeStyle", outline_color="&H00000000")
|
||
# OutlineColour 是 index 5
|
||
parts = result.split(",")
|
||
assert parts[5] == "&H00000000"
|
||
|
||
def test_field_count(self):
|
||
"""验证 Style 行有正确的字段数(23 个字段)."""
|
||
result = build_ass_style("FullStyle")
|
||
parts = result.split(",")
|
||
# Style: 行有 23 个字段(去掉 "Style: " 前缀后)
|
||
assert len(parts) == 23
|
||
|
||
def test_font_name_mapping_siyuan(self):
|
||
"""思源黑体 → Noto Sans SC"""
|
||
result = build_ass_style("Test", font_name="思源黑体")
|
||
assert "Noto Sans SC" in result
|
||
|
||
def test_font_name_mapping_apple(self):
|
||
"""苹方 → Noto Sans SC"""
|
||
result = build_ass_style("Test", font_name="苹方")
|
||
assert "Noto Sans SC" in result
|
||
|
||
def test_font_name_mapping_msyh(self):
|
||
"""微软雅黑 → Noto Sans SC"""
|
||
result = build_ass_style("Test", font_name="微软雅黑")
|
||
assert "Noto Sans SC" in result
|
||
|
||
def test_font_name_mapping_unknown_passthrough(self):
|
||
"""未映射字体原样使用"""
|
||
result = build_ass_style("Test", font_name="CustomFont")
|
||
assert "CustomFont" in result
|
||
|
||
|
||
# ── 文本转义 ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestEscapeAssText:
|
||
def test_plain_text(self):
|
||
assert escape_ass_text("Hello World") == "Hello World"
|
||
|
||
def test_newline_lf(self):
|
||
assert escape_ass_text("line1\nline2") == "line1\\Nline2"
|
||
|
||
def test_newline_crlf(self):
|
||
assert escape_ass_text("line1\r\nline2") == "line1\\Nline2"
|
||
|
||
def test_newline_cr(self):
|
||
assert escape_ass_text("line1\rline2") == "line1\\Nline2"
|
||
|
||
def test_curly_braces(self):
|
||
assert escape_ass_text("text {tag} text") == "text (tag) text"
|
||
|
||
def test_left_brace_only(self):
|
||
assert escape_ass_text("{start") == "(start"
|
||
|
||
def test_right_brace_only(self):
|
||
assert escape_ass_text("end}") == "end)"
|
||
|
||
def test_multiple_braces(self):
|
||
assert escape_ass_text("{a}{b}{c}") == "(a)(b)(c)"
|
||
|
||
def test_mixed_newline_and_braces(self):
|
||
assert escape_ass_text("line1\n{tag}\nline2") == "line1\\N(tag)\\Nline2"
|
||
|
||
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_mixed_slashes_and_newlines(self):
|
||
"""斜杠和换行符都应转为硬换行。"""
|
||
result = escape_ass_text("A/B\nC/D")
|
||
assert result == "A\\NB\\NC\\ND"
|
||
|
||
|
||
# ── 时间格式化 ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
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(125.0) == "0:02:05.00"
|
||
|
||
def test_hours(self):
|
||
assert format_ass_time(3661.5) == "1:01:01.50"
|
||
|
||
def test_one_hour_exact(self):
|
||
assert format_ass_time(3600) == "1:00:00.00"
|
||
|
||
def test_sub_second_precision(self):
|
||
result = format_ass_time(1.23)
|
||
assert result == "0:00:01.23"
|
||
|
||
def test_59_seconds(self):
|
||
assert format_ass_time(59.99) == "0:00:59.99"
|
||
|
||
def test_60_seconds(self):
|
||
assert format_ass_time(60.0) == "0:01:00.00"
|
||
|
||
def test_90_minutes(self):
|
||
assert format_ass_time(5400.0) == "1:30:00.00"
|
||
|
||
|
||
# ── 完整 ASS 内容生成 ─────────────────────────────────────────────────────────
|
||
|
||
|
||
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_disabled_returns_empty(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=10.0,
|
||
title_text="Title",
|
||
title_config={"enabled": False},
|
||
)
|
||
assert result == ""
|
||
|
||
def test_empty_title_text_returns_empty(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=10.0,
|
||
title_text=" ",
|
||
title_config={"enabled": True},
|
||
)
|
||
assert result == ""
|
||
|
||
def test_with_title(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=30.0,
|
||
title_text="My Title",
|
||
title_config={"enabled": True, "color": "#FFFFFF"},
|
||
)
|
||
assert "[Script Info]" in result
|
||
assert "PlayResX: 1920" in result
|
||
assert "PlayResY: 1080" in result
|
||
assert "[V4+ Styles]" in result
|
||
assert "TitleStyle" in result
|
||
assert "[Events]" in result
|
||
assert "Dialogue:" in result
|
||
assert "My Title" in result
|
||
|
||
def test_with_subtitle(self):
|
||
result = build_ass_content(
|
||
video_width=1280,
|
||
video_height=720,
|
||
video_duration=15.0,
|
||
subtitle_text="Subtitle Text",
|
||
subtitle_config={"enabled": True},
|
||
)
|
||
assert "PlayResX: 1280" in result
|
||
assert "PlayResY: 720" in result
|
||
assert "SubtitleStyle" in result
|
||
assert "Subtitle Text" in result
|
||
|
||
def test_with_both_title_and_subtitle(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=60.0,
|
||
title_text="Big Title",
|
||
title_config={"enabled": True},
|
||
subtitle_text="Small subtitle",
|
||
subtitle_config={"enabled": True},
|
||
)
|
||
assert "TitleStyle" in result
|
||
assert "SubtitleStyle" in result
|
||
assert "Big Title" in result
|
||
assert "Small subtitle" in result
|
||
# 两个 Dialogue 行
|
||
assert result.count("Dialogue:") == 2
|
||
|
||
def test_title_position_bottom(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=10.0,
|
||
title_text="Bottom Title",
|
||
title_config={"enabled": True, "position": "bottom"},
|
||
)
|
||
# 对齐方式为 2(底部居中)
|
||
assert "TitleStyle" in result
|
||
|
||
def test_title_with_stroke(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=10.0,
|
||
title_text="Stroke Title",
|
||
title_config={
|
||
"enabled": True,
|
||
"stroke": {"enabled": True, "color": "#000000", "width": 3},
|
||
},
|
||
)
|
||
assert "Stroke Title" in result
|
||
|
||
def test_title_with_shadow(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=10.0,
|
||
title_text="Shadow Title",
|
||
title_config={
|
||
"enabled": True,
|
||
"shadow": {"enabled": True, "blur": 4, "offset_x": 2, "offset_y": 3},
|
||
},
|
||
)
|
||
assert "Shadow Title" in result
|
||
|
||
def test_title_bold_default(self):
|
||
"""标题默认启用粗体."""
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=10.0,
|
||
title_text="Bold Title",
|
||
title_config={"enabled": True},
|
||
)
|
||
# 在 TitleStyle 行中找 bold=-1
|
||
for line in result.split("\n"):
|
||
if line.startswith("Style: TitleStyle"):
|
||
parts = line.split(",")
|
||
assert parts[7] == "-1"
|
||
break
|
||
else:
|
||
pytest.fail("TitleStyle not found")
|
||
|
||
def test_subtitle_not_bold(self):
|
||
"""字幕默认不启用粗体."""
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=10.0,
|
||
subtitle_text="Normal Subtitle",
|
||
subtitle_config={"enabled": True},
|
||
)
|
||
for line in result.split("\n"):
|
||
if line.startswith("Style: SubtitleStyle"):
|
||
parts = line.split(",")
|
||
assert parts[7] == "0"
|
||
break
|
||
else:
|
||
pytest.fail("SubtitleStyle not found")
|
||
|
||
def test_duration_format_in_dialogue(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=125.5,
|
||
title_text="Timed",
|
||
title_config={"enabled": True},
|
||
)
|
||
# 结束时间应该是 0:02:05.50
|
||
assert "0:02:05.50" in result
|
||
|
||
def test_title_text_escaped(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=10.0,
|
||
title_text="Line1\n{tag}Line2",
|
||
title_config={"enabled": True},
|
||
)
|
||
assert "Line1\\N(tag)Line2" in result
|
||
|
||
def test_default_title_enabled(self):
|
||
"""不传 enabled 时默认为 True."""
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=10.0,
|
||
title_text="Default Enabled",
|
||
title_config={},
|
||
)
|
||
assert result != ""
|
||
assert "Default Enabled" in result
|
||
|
||
def test_subtitle_position_top(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=10.0,
|
||
subtitle_text="Top Subtitle",
|
||
subtitle_config={"enabled": True, "position": "top"},
|
||
)
|
||
assert "Top Subtitle" in result
|
||
|
||
def test_scaled_border_and_shadow(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=10.0,
|
||
title_text="Test",
|
||
title_config={"enabled": True},
|
||
)
|
||
assert "ScaledBorderAndShadow: yes" in result
|
||
|
||
def test_wrap_style(self):
|
||
result = build_ass_content(
|
||
video_width=1920,
|
||
video_height=1080,
|
||
video_duration=10.0,
|
||
title_text="Test",
|
||
title_config={"enabled": True},
|
||
)
|
||
assert "WrapStyle: 2" in result
|
||
|
||
|
||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestConstants:
|
||
def test_title_margin_top(self):
|
||
assert TITLE_MARGIN_TOP == 120
|
||
|
||
def test_title_margin_bottom(self):
|
||
assert TITLE_MARGIN_BOTTOM == 60
|
||
|
||
def test_title_margin_side(self):
|
||
assert TITLE_MARGIN_SIDE == 40
|
||
|
||
|
||
# ── 标题自动换行 ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestWrapTitleText:
|
||
"""测试标题自动换行逻辑。"""
|
||
|
||
def test_short_title_no_wrap(self):
|
||
"""短标题不需要换行。"""
|
||
result = _wrap_title_text("你好世界", video_width=1080, font_size=48)
|
||
assert "\\N" not in result
|
||
assert result == "你好世界"
|
||
|
||
def test_long_title_wraps(self):
|
||
"""长标题应该被换行。"""
|
||
# 1080p竖屏 480px宽,字号48,边距40*2
|
||
# 可用宽度 = 480 - 40 - 40 = 400
|
||
# 每个中文字符 = 48px,最多约 8 个字符一行
|
||
long_title = "这是一个非常非常长的标题需要换行处理"
|
||
result = _wrap_title_text(long_title, video_width=480, font_size=48)
|
||
assert "\\N" in result
|
||
lines = result.split("\\N")
|
||
assert len(lines) >= 2
|
||
|
||
def test_empty_text(self):
|
||
"""空文本直接返回。"""
|
||
assert _wrap_title_text("", 1080, 48) == ""
|
||
|
||
def test_english_half_width(self):
|
||
"""英文字符按半角计算。"""
|
||
# 英文宽度 = 48 * 0.55 = 26.4px
|
||
# 可用宽度 = 480 - 80 = 400, 约15个字符
|
||
result = _wrap_title_text("a" * 20, video_width=480, font_size=48)
|
||
# 20个英文字符 * 26.4 = 528 > 400,应该换行
|
||
assert "\\N" in result
|
||
|
||
def test_zero_width(self):
|
||
"""视频宽度为0时直接返回原文。"""
|
||
assert _wrap_title_text("测试", 0, 48) == "测试"
|
||
|
||
def test_zero_font_size(self):
|
||
"""字号为0时直接返回原文。"""
|
||
assert _wrap_title_text("测试", 1080, 0) == "测试"
|
||
|
||
def test_preserves_explicit_newline(self):
|
||
"""已有的 \\N 换行标记应保留,不被当普通字符算宽度。"""
|
||
text = "第一行\\N第二行"
|
||
result = _wrap_title_text(text, video_width=1080, font_size=48)
|
||
assert result == text
|
||
|
||
def test_explicit_newline_each_segment_wraps_independently(self):
|
||
"""\\N 分段后,每段各自自动换行。"""
|
||
# 480px 宽,48px 字号,可用 400px,每段约8个中文字
|
||
text = "这是第一段很长很长很长的内容\\N这是第二段也很长很长的内容"
|
||
result = _wrap_title_text(text, video_width=480, font_size=48)
|
||
# 应该有多个 \N:用户手动的 + 自动换行的
|
||
assert "\\N" in result
|
||
segments = result.split("\\N")
|
||
# 至少3行(两段都需要换行)
|
||
assert len(segments) >= 3
|
||
# 验证包含两段的文字
|
||
joined = result.replace("\\N", "")
|
||
assert "第一段" in joined
|
||
assert "第二段" in joined
|
||
|
||
def test_multiple_explicit_newlines(self):
|
||
"""多个 \\N 分段都应保留。"""
|
||
text = "A\\NB\\NC"
|
||
result = _wrap_title_text(text, video_width=1080, font_size=48)
|
||
assert result == text
|
||
assert result.count("\\N") == 2
|
||
|
||
def test_build_ass_content_integration(self):
|
||
"""集成测试:build_ass_content 中的标题应该自动换行。"""
|
||
long_title = "这是一段非常长的标题文字用于测试自动换行功能是否正常工作"
|
||
content = build_ass_content(
|
||
video_width=480,
|
||
video_height=854,
|
||
video_duration=10.0,
|
||
title_text=long_title,
|
||
title_config={"size": 48, "position": "top"},
|
||
)
|
||
# 检查 Dialogue 行中包含 \N 换行
|
||
for line in content.split("\n"):
|
||
if "Dialogue" in line and "TitleStyle" in line:
|
||
assert "\\N" in line, f"标题应该包含换行符: {line}"
|
||
break
|
||
else:
|
||
pytest.fail("未找到 TitleStyle Dialogue 行")
|
||
|
||
|
||
class TestFontsizeCompensation:
|
||
"""ASS Fontsize 补偿系数(CSS 字号 → ASS em-square 字号)。"""
|
||
|
||
def test_default_48_compensated_to_65(self):
|
||
result = build_ass_style("S")
|
||
parts = result.split(",")
|
||
assert parts[2] == "65" # round(48*1.35)=65
|
||
|
||
def test_89_compensated_to_120(self):
|
||
"""实测对齐点:font_size=89 → ASS Fontsize=120。"""
|
||
result = build_ass_style("S", font_size=89)
|
||
parts = result.split(",")
|
||
assert parts[2] == "120"
|
||
|
||
def test_subtitle_also_compensated(self):
|
||
content = build_ass_content(
|
||
video_width=1080,
|
||
video_height=1920,
|
||
video_duration=5.0,
|
||
subtitle_text="字幕",
|
||
subtitle_config={"size": 24},
|
||
)
|
||
sub_line = [line for line in content.splitlines() if line.startswith("Style: SubtitleStyle")][0]
|
||
fields = [f.strip() for f in sub_line.split(",")]
|
||
assert fields[2] == "32" # round(24*1.35)=32
|
||
|
||
def test_minimum_fontsize_at_least_one(self):
|
||
result = build_ass_style("S", font_size=0)
|
||
parts = result.split(",")
|
||
assert int(parts[2]) >= 1
|
||
|
||
|
||
# ── 标题自由位置拖拽(工单 #1405 方案 B)──────────────────────────────────────
|
||
|
||
|
||
def _title_style_line(content: str) -> str:
|
||
return [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
|
||
|
||
|
||
def _title_dialogue_line(content: str) -> str:
|
||
return [line for line in content.splitlines() if line.startswith("Dialogue:") and "TitleStyle" in line][0]
|
||
|
||
|
||
class TestTitleFreePosition:
|
||
"""pos_x/pos_y 合法时注入 \\pos 且 Alignment=5;非法/缺失时回退原逻辑。"""
|
||
|
||
def _base_kwargs(self):
|
||
return dict(
|
||
video_width=1080,
|
||
video_height=1920,
|
||
video_duration=8.0,
|
||
title_text="测试标题",
|
||
)
|
||
|
||
def test_valid_position_injects_pos_tag_and_alignment_5(self):
|
||
content = build_ass_content(
|
||
**self._base_kwargs(),
|
||
title_config={"position": "top", "size": 36, "pos_x": 540, "pos_y": 300},
|
||
)
|
||
# Dialogue 文本前注入 {\pos(540,300)}
|
||
dialogue = _title_dialogue_line(content)
|
||
assert "{\\pos(540,300)}" in dialogue
|
||
# TitleStyle Alignment 固定 5(\an5 中对齐,\pos 锚点为文本块中心)
|
||
fields = [f.strip() for f in _title_style_line(content).split(",")]
|
||
assert fields[18] == "5"
|
||
|
||
def test_boundary_coordinates_zero_and_max_accepted(self):
|
||
"""边界值 0 和 video_width/video_height 合法(闭区间)。"""
|
||
content = build_ass_content(
|
||
**self._base_kwargs(),
|
||
title_config={"pos_x": 0, "pos_y": 1920},
|
||
)
|
||
assert "{\\pos(0,1920)}" in _title_dialogue_line(content)
|
||
|
||
content2 = build_ass_content(
|
||
**self._base_kwargs(),
|
||
title_config={"pos_x": 1080, "pos_y": 0},
|
||
)
|
||
assert "{\\pos(1080,0)}" in _title_dialogue_line(content2)
|
||
|
||
def test_no_coords_output_identical_to_before(self):
|
||
"""不传坐标 → 输出与现有断言完全一致(回归保护)。"""
|
||
content = build_ass_content(
|
||
**self._base_kwargs(),
|
||
title_config={"position": "top", "size": 36},
|
||
)
|
||
# 无 \pos 注入
|
||
assert "\\pos(" not in content
|
||
# Alignment 走 position 映射(top → 8)
|
||
fields = [f.strip() for f in _title_style_line(content).split(",")]
|
||
assert fields[18] == "8"
|
||
|
||
def test_out_of_bounds_falls_back(self):
|
||
"""越界坐标 → 回退 position 三档逻辑,输出与无坐标一致。"""
|
||
base = build_ass_content(
|
||
**self._base_kwargs(),
|
||
title_config={"position": "top", "size": 36},
|
||
)
|
||
for pos_x, pos_y in [(-1, 300), (540, -1), (1081, 300), (540, 1921), (99999, 99999)]:
|
||
content = build_ass_content(
|
||
**self._base_kwargs(),
|
||
title_config={"position": "top", "size": 36, "pos_x": pos_x, "pos_y": pos_y},
|
||
)
|
||
assert "\\pos(" not in content, f"({pos_x},{pos_y}) should be rejected"
|
||
assert content == base, f"({pos_x},{pos_y}) output differs from fallback"
|
||
|
||
def test_invalid_coords_falls_back(self):
|
||
"""非法类型坐标 → 回退原逻辑。"""
|
||
base = build_ass_content(
|
||
**self._base_kwargs(),
|
||
title_config={"position": "top", "size": 36},
|
||
)
|
||
for pos_x, pos_y in [("abc", 300), (540, None), (None, None), (True, 300), (540, False), (540.5, 300.9)]:
|
||
content = build_ass_content(
|
||
**self._base_kwargs(),
|
||
title_config={"position": "top", "size": 36, "pos_x": pos_x, "pos_y": pos_y},
|
||
)
|
||
assert "\\pos(" not in content, f"({pos_x!r},{pos_y!r}) should be rejected"
|
||
assert content == base, f"({pos_x!r},{pos_y!r}) output differs from fallback"
|
||
|
||
def test_only_one_coord_falls_back(self):
|
||
"""只传 pos_x 或 pos_y → 回退原逻辑。"""
|
||
base = build_ass_content(
|
||
**self._base_kwargs(),
|
||
title_config={"position": "center", "size": 36},
|
||
)
|
||
content_x = build_ass_content(
|
||
**self._base_kwargs(),
|
||
title_config={"position": "center", "size": 36, "pos_x": 540},
|
||
)
|
||
content_y = build_ass_content(
|
||
**self._base_kwargs(),
|
||
title_config={"position": "center", "size": 36, "pos_y": 300},
|
||
)
|
||
assert content_x == base
|
||
assert content_y == base
|
||
assert "\\pos(" not in content_x
|
||
assert "\\pos(" not in content_y
|
||
|
||
def test_position_three_levels_unchanged_without_coords(self):
|
||
"""无坐标时 top/center/bottom 三档 Alignment 输出不变。"""
|
||
for position, expected_align in [("top", "8"), ("center", "5"), ("bottom", "2")]:
|
||
content = build_ass_content(
|
||
**self._base_kwargs(),
|
||
title_config={"position": position, "size": 36},
|
||
)
|
||
fields = [f.strip() for f in _title_style_line(content).split(",")]
|
||
assert fields[18] == expected_align
|
||
|
||
def test_pos_overrides_position_alignment(self):
|
||
"""有合法坐标时,无论 position 是什么,Alignment 都固定为 5。"""
|
||
for position in ["top", "center", "bottom"]:
|
||
content = build_ass_content(
|
||
**self._base_kwargs(),
|
||
title_config={"position": position, "size": 36, "pos_x": 100, "pos_y": 200},
|
||
)
|
||
fields = [f.strip() for f in _title_style_line(content).split(",")]
|
||
assert fields[18] == "5"
|
||
assert "{\\pos(100,200)}" in _title_dialogue_line(content)
|
||
|
||
def test_subtitle_not_affected_by_pos(self):
|
||
"""pos_x/pos_y 只影响 Title,Subtitle 输出不变。"""
|
||
content = build_ass_content(
|
||
**self._base_kwargs(),
|
||
title_config={"pos_x": 540, "pos_y": 300},
|
||
subtitle_text="配音字幕",
|
||
subtitle_config={"position": "bottom", "size": 24},
|
||
)
|
||
sub_style = [line for line in content.splitlines() if line.startswith("Style: SubtitleStyle")][0]
|
||
sub_fields = [f.strip() for f in sub_style.split(",")]
|
||
assert sub_fields[18] == "2" # bottom
|
||
sub_dialogue = [
|
||
line for line in content.splitlines() if line.startswith("Dialogue:") and "SubtitleStyle" in line
|
||
][0]
|
||
assert "\\pos(" not in sub_dialogue
|
||
|
||
|
||
class TestDefaultPositionBottom:
|
||
"""默认 position 应为 bottom(alignment=2),与前端 DEFAULT_TITLE_SETTINGS 对齐。"""
|
||
|
||
def _base_kwargs(self):
|
||
return dict(
|
||
video_width=1080,
|
||
video_height=1920,
|
||
video_duration=10.0,
|
||
title_text="测试标题",
|
||
)
|
||
|
||
def test_no_position_defaults_to_bottom_alignment(self):
|
||
"""不传 position 时,Alignment 应为 2(bottom)。"""
|
||
content = build_ass_content(
|
||
**self._base_kwargs(),
|
||
title_config={"size": 36},
|
||
)
|
||
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
|
||
fields = [f.strip() for f in style_line.split(",")]
|
||
assert fields[18] == "2", f"Expected alignment 2 (bottom), got {fields[18]}"
|
||
|
||
def test_no_position_no_coords_defaults_to_bottom(self):
|
||
"""不传 position 也不传坐标时,走 bottom 三档逻辑。"""
|
||
content = build_ass_content(
|
||
**self._base_kwargs(),
|
||
title_config={},
|
||
)
|
||
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
|
||
fields = [f.strip() for f in style_line.split(",")]
|
||
assert fields[18] == "2"
|
||
|
||
def test_explicit_top_still_works(self):
|
||
"""显式传 position='top' 仍然得到 alignment=8。"""
|
||
content = build_ass_content(
|
||
**self._base_kwargs(),
|
||
title_config={"position": "top", "size": 36},
|
||
)
|
||
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
|
||
fields = [f.strip() for f in style_line.split(",")]
|
||
assert fields[18] == "8"
|