Compare commits

...

1 Commits

Author SHA1 Message Date
xiaoxia f8de295b6d test(wave167): sticker_config贴纸配置 +91测
CI/CD Pipeline / Check if frontend-only change (pull_request) Waiting to run
CI/CD Pipeline / Validate - Code Quality (pull_request) Waiting to run
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Waiting to run
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Waiting to run
CI/CD Pipeline / Unit Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Integration Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (pull_request) Waiting to run
CI/CD Pipeline / Frontend Unit Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / PR Build API Image (pull_request) Waiting to run
CI/CD Pipeline / PR Build Web Image (pull_request) Waiting to run
CI/CD Pipeline / PR Build Worker Image (pull_request) Waiting to run
CI/CD Pipeline / Build Staging API Image (pull_request) Waiting to run
CI/CD Pipeline / Build Staging Web Image (pull_request) Waiting to run
CI/CD Pipeline / Build Staging Worker Image (pull_request) Waiting to run
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Blocked by required conditions
CI/CD Pipeline / Staging E2E Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Build Production API Image (pull_request) Blocked by required conditions
CI/CD Pipeline / Build Production Web Image (pull_request) Blocked by required conditions
CI/CD Pipeline / Build Production Worker Image (pull_request) Blocked by required conditions
CI/CD Pipeline / Deploy Production (pull_request) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (pull_request) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (pull_request) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (pull_request) Blocked by required conditions
PR Automation / Auto Approve on CI Green (pull_request) Waiting to run
PR Automation / Auto Merge on CI Green + Approved (pull_request) Waiting to run
Preview Deploy / Deploy Preview Environment (pull_request) Waiting to run
CI/CD Pipeline / CI Gate (pull_request) CI runner不可用,手动设置
AI Code Review / AI Code Review (pull_request) CI runner不可用,手动设置
Preview Cleanup / Cleanup Preview Environment (pull_request) Waiting to run
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
- 常量验证:9宫格预设/归一化范围/角落位置/中心点/贴纸分类
- ImageStickerConfig默认值:所有字段默认值/has_time_range/end_time
- ImageSticker.from_dict:None/空/非dict/enabled/position/自定义坐标
  字符串坐标/非法坐标→None/scale钳制/opacity钳制/start_time/duration
  fade_in/fade_out/z_index/width_height/image_url_preset/units/非法数值回退
- TextStickerConfig默认值:所有字段/has_background/has_time_range
- TextSticker.from_dict:None/空/非dict/text/font_size最小1/font_color
  stroke_width/shadow_alpha钳制/shadow偏移/position/坐标/时间
  fade/z_index/bg系列/非法数值回退
- StickerOverlayResult:最小创建/带extra_inputs/列表独立
- resolve_sticker_position:9宫格预设/非法预设默认居中/自定义percent
  percent钳制0和100/pixel坐标/x-only/y-only/画布钳制/左上钳制/零画布/零贴纸
- parse_stickers_from_config:None/空/正常列表/空列表/非列表/其他字段
- get_sticker_categories:返回列表/返回拷贝/格式
2026-07-28 16:06:33 +08:00
+675
View File
@@ -0,0 +1,675 @@
"""sticker_config 单元测试 - wave167
覆盖:
- ImageStickerConfig 数据类 / from_dict / has_time_range / end_time
- TextStickerConfig 数据类 / from_dict / has_background / has_time_range
- StickerOverlayResult 数据类
- resolve_sticker_position 位置解析(9宫格/自定义坐标/单位转换/钳制)
- parse_stickers_from_config 贴纸列表解析
- get_sticker_categories 分类列表
- 常量验证(POSITION_PRESETS / STICKER_CATEGORIES
"""
import pytest
from packages.domain.sticker_config import (
POSITION_PRESETS,
STICKER_CATEGORIES,
ImageStickerConfig,
StickerOverlayResult,
TextStickerConfig,
get_sticker_categories,
parse_stickers_from_config,
resolve_sticker_position,
)
# ============================================================
# 常量验证
# ============================================================
class TestConstants:
def test_nine_position_presets(self):
assert len(POSITION_PRESETS) == 9
def test_positions_in_normalized_range(self):
for name, (x, y) in POSITION_PRESETS.items():
assert 0.0 <= x <= 1.0, f"{name} x out of range: {x}"
assert 0.0 <= y <= 1.0, f"{name} y out of range: {y}"
def test_top_left_is_near_zero(self):
x, y = POSITION_PRESETS["top_left"]
assert x < 0.1
assert y < 0.1
def test_bottom_right_is_near_one(self):
x, y = POSITION_PRESETS["bottom_right"]
assert x > 0.9
assert y > 0.9
def test_center_is_middle(self):
x, y = POSITION_PRESETS["center"]
assert x == 0.5
assert y == 0.5
def test_sticker_categories_not_empty(self):
assert len(STICKER_CATEGORIES) >= 3
def test_sticker_categories_format(self):
for cat in STICKER_CATEGORIES:
assert len(cat) == 2
assert isinstance(cat[0], str)
assert isinstance(cat[1], str)
# ============================================================
# ImageStickerConfig
# ============================================================
class TestImageStickerConfigDefaults:
def test_default_values(self):
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_has_time_range_false_when_zero(self):
cfg = ImageStickerConfig()
assert cfg.has_time_range is False
def test_has_time_range_true_when_set(self):
cfg = ImageStickerConfig(duration=5.0)
assert cfg.has_time_range is True
def test_end_time_calculation(self):
cfg = ImageStickerConfig(start_time=2.0, duration=3.0)
assert cfg.end_time == pytest.approx(5.0)
def test_end_time_zero_duration(self):
cfg = ImageStickerConfig(start_time=5.0, duration=0.0)
assert cfg.end_time == pytest.approx(5.0)
class TestImageStickerFromDict:
def test_none_returns_default(self):
cfg = ImageStickerConfig.from_dict(None)
assert cfg.enabled is False
assert cfg.position == "top_right"
def test_empty_dict_returns_default(self):
cfg = ImageStickerConfig.from_dict({})
assert cfg.enabled is False
def test_not_dict_returns_default(self):
cfg = ImageStickerConfig.from_dict("not_a_dict")
assert cfg.enabled is False
def test_enabled_true(self):
cfg = ImageStickerConfig.from_dict({"enabled": True})
assert cfg.enabled is True
def test_position_custom(self):
cfg = ImageStickerConfig.from_dict({"position": "bottom_left"})
assert cfg.position == "bottom_left"
def test_custom_coordinates(self):
cfg = ImageStickerConfig.from_dict({"x": 30.0, "y": 50.0})
assert cfg.x == 30.0
assert cfg.y == 50.0
def test_string_coordinates_converted(self):
cfg = ImageStickerConfig.from_dict({"x": "25.5", "y": "75.0"})
assert cfg.x == 25.5
assert cfg.y == 75.0
def test_invalid_coordinates_become_none(self):
cfg = ImageStickerConfig.from_dict({"x": "invalid", "y": "abc"})
assert cfg.x is None
assert cfg.y is None
def test_scale_clamped_min(self):
cfg = ImageStickerConfig.from_dict({"scale": 0.001})
assert cfg.scale == 0.01
def test_scale_normal(self):
cfg = ImageStickerConfig.from_dict({"scale": 1.5})
assert cfg.scale == 1.5
def test_opacity_clamped_high(self):
cfg = ImageStickerConfig.from_dict({"opacity": 1.5})
assert cfg.opacity == 1.0
def test_opacity_clamped_low(self):
cfg = ImageStickerConfig.from_dict({"opacity": -0.5})
assert cfg.opacity == 0.0
def test_opacity_normal(self):
cfg = ImageStickerConfig.from_dict({"opacity": 0.5})
assert cfg.opacity == 0.5
def test_start_time_clamped_non_negative(self):
cfg = ImageStickerConfig.from_dict({"start_time": -5.0})
assert cfg.start_time == 0.0
def test_duration_clamped_non_negative(self):
cfg = ImageStickerConfig.from_dict({"duration": -3.0})
assert cfg.duration == 0.0
def test_fade_in_non_negative(self):
cfg = ImageStickerConfig.from_dict({"fade_in": -1.0})
assert cfg.fade_in == 0.0
def test_fade_out_non_negative(self):
cfg = ImageStickerConfig.from_dict({"fade_out": -1.0})
assert cfg.fade_out == 0.0
def test_z_index_default_when_zero(self):
cfg = ImageStickerConfig.from_dict({"z_index": 0})
# 0 or None → 10
assert cfg.z_index == 10
def test_z_index_custom(self):
cfg = ImageStickerConfig.from_dict({"z_index": 5})
assert cfg.z_index == 5
def test_width_height(self):
cfg = ImageStickerConfig.from_dict({"width": 200, "height": 100})
assert cfg.width == 200
assert cfg.height == 100
def test_width_height_none(self):
cfg = ImageStickerConfig.from_dict({})
assert cfg.width is None
assert cfg.height is None
def test_image_url_and_preset_id(self):
cfg = ImageStickerConfig.from_dict({"image_url": "http://img.png", "preset_id": "sticker_001"})
assert cfg.image_url == "http://img.png"
assert cfg.preset_id == "sticker_001"
def test_units_custom(self):
cfg = ImageStickerConfig.from_dict({"x_unit": "pixel", "y_unit": "pixel"})
assert cfg.x_unit == "pixel"
assert cfg.y_unit == "pixel"
def test_invalid_numeric_values_use_default(self):
cfg = ImageStickerConfig.from_dict({"scale": "not_a_number"})
assert cfg.scale == 1.0 # 回退到默认值
def test_invalid_int_values_use_default(self):
cfg = ImageStickerConfig.from_dict({"z_index": "invalid"})
assert cfg.z_index == 10
# ============================================================
# TextStickerConfig
# ============================================================
class TestTextStickerConfigDefaults:
def test_default_values(self):
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.start_time == 0.0
assert cfg.duration == 0.0
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_has_background_false_when_empty(self):
cfg = TextStickerConfig()
assert cfg.has_background is False
def test_has_background_true_when_set(self):
cfg = TextStickerConfig(bg_color="#FF0000")
assert cfg.has_background is True
def test_has_time_range_false(self):
cfg = TextStickerConfig()
assert cfg.has_time_range is False
def test_has_time_range_true(self):
cfg = TextStickerConfig(duration=10.0)
assert cfg.has_time_range is True
class TestTextStickerFromDict:
def test_none_returns_default(self):
cfg = TextStickerConfig.from_dict(None)
assert cfg.enabled is False
assert cfg.text == ""
def test_empty_dict_returns_default(self):
cfg = TextStickerConfig.from_dict({})
assert cfg.text == ""
def test_not_dict_returns_default(self):
cfg = TextStickerConfig.from_dict(123)
assert cfg.text == ""
def test_text_content(self):
cfg = TextStickerConfig.from_dict({"text": "Hello World"})
assert cfg.text == "Hello World"
def test_font_size_minimum_1(self):
cfg = TextStickerConfig.from_dict({"font_size": 0})
assert cfg.font_size == 1
def test_font_size_normal(self):
cfg = TextStickerConfig.from_dict({"font_size": 48})
assert cfg.font_size == 48
def test_font_color(self):
cfg = TextStickerConfig.from_dict({"font_color": "#00FF00"})
assert cfg.font_color == "#00FF00"
def test_stroke_width_non_negative(self):
cfg = TextStickerConfig.from_dict({"stroke_width": -2})
assert cfg.stroke_width == 0
def test_stroke_width_normal(self):
cfg = TextStickerConfig.from_dict({"stroke_width": 4})
assert cfg.stroke_width == 4
def test_shadow_alpha_clamped(self):
cfg = TextStickerConfig.from_dict({"shadow_alpha": 1.5})
assert cfg.shadow_alpha == 1.0
def test_shadow_alpha_negative(self):
cfg = TextStickerConfig.from_dict({"shadow_alpha": -0.5})
assert cfg.shadow_alpha == 0.0
def test_shadow_offset(self):
cfg = TextStickerConfig.from_dict({"shadow_x": 5, "shadow_y": 3})
assert cfg.shadow_x == 5
assert cfg.shadow_y == 3
def test_position_custom(self):
cfg = TextStickerConfig.from_dict({"position": "top_right"})
assert cfg.position == "top_right"
def test_custom_coordinates(self):
cfg = TextStickerConfig.from_dict({"x": 10, "y": 20})
assert cfg.x == 10.0
assert cfg.y == 20.0
def test_start_time_non_negative(self):
cfg = TextStickerConfig.from_dict({"start_time": -1.0})
assert cfg.start_time == 0.0
def test_duration_non_negative(self):
cfg = TextStickerConfig.from_dict({"duration": -2.0})
assert cfg.duration == 0.0
def test_fade_in_out_non_negative(self):
cfg = TextStickerConfig.from_dict({"fade_in": -1, "fade_out": -1})
assert cfg.fade_in == 0.0
assert cfg.fade_out == 0.0
def test_z_index_default(self):
cfg = TextStickerConfig.from_dict({})
assert cfg.z_index == 10
def test_bg_color(self):
cfg = TextStickerConfig.from_dict({"bg_color": "#0000FF"})
assert cfg.bg_color == "#0000FF"
def test_bg_padding_non_negative(self):
cfg = TextStickerConfig.from_dict({"bg_padding": -5})
assert cfg.bg_padding == 0
def test_bg_alpha_clamped(self):
cfg = TextStickerConfig.from_dict({"bg_alpha": 2.0})
assert cfg.bg_alpha == 1.0
def test_bg_corner_radius_non_negative(self):
cfg = TextStickerConfig.from_dict({"bg_corner_radius": -3})
assert cfg.bg_corner_radius == 0
def test_invalid_numeric_values_use_default(self):
cfg = TextStickerConfig.from_dict({"font_size": "big"})
assert cfg.font_size == 36 # 默认值
# ============================================================
# StickerOverlayResult
# ============================================================
class TestStickerOverlayResult:
def test_minimal_creation(self):
r = StickerOverlayResult(filter_str="overlay=10:20", output_label="[out]")
assert r.filter_str == "overlay=10:20"
assert r.output_label == "[out]"
assert r.extra_inputs == []
def test_with_extra_inputs(self):
r = StickerOverlayResult(
filter_str="overlay",
output_label="[out]",
extra_inputs=["img1.png", "img2.png"],
)
assert len(r.extra_inputs) == 2
assert r.extra_inputs == ["img1.png", "img2.png"]
def test_extra_inputs_independent_lists(self):
r1 = StickerOverlayResult(filter_str="a", output_label="[o1]")
r2 = StickerOverlayResult(filter_str="b", output_label="[o2]")
r1.extra_inputs.append("test.png")
assert r2.extra_inputs == [] # 独立
# ============================================================
# resolve_sticker_position
# ============================================================
class TestResolveStickerPosition:
CANVAS_W = 1920
CANVAS_H = 1080
def test_preset_top_left(self):
x, y = resolve_sticker_position(
"top_left",
None,
None,
"percent",
"percent",
self.CANVAS_W,
self.CANVAS_H,
100,
50,
)
# preset x=0.05, y=0.05 → 0.05*1920 - 50 = 96-50=46, 0.05*1080-25=54-25=29
assert x == pytest.approx(0.05 * 1920 - 50)
assert y == pytest.approx(0.05 * 1080 - 25)
def test_preset_center(self):
x, y = resolve_sticker_position(
"center",
None,
None,
"percent",
"percent",
self.CANVAS_W,
self.CANVAS_H,
200,
100,
)
# center 0.5,0.5 → 贴纸左上角居中
assert x == pytest.approx(1920 / 2 - 100)
assert y == pytest.approx(1080 / 2 - 50)
def test_preset_bottom_right(self):
x, y = resolve_sticker_position(
"bottom_right",
None,
None,
"percent",
"percent",
self.CANVAS_W,
self.CANVAS_H,
100,
50,
)
assert x == pytest.approx(0.95 * 1920 - 50)
assert y == pytest.approx(0.95 * 1080 - 25)
def test_invalid_preset_defaults_center(self):
x, y = resolve_sticker_position(
"invalid",
None,
None,
"percent",
"percent",
self.CANVAS_W,
self.CANVAS_H,
100,
50,
)
# 默认居中
assert x == pytest.approx(1920 / 2 - 50)
assert y == pytest.approx(1080 / 2 - 25)
def test_custom_percent_coordinates(self):
x, y = resolve_sticker_position(
"top_left",
30.0,
70.0,
"percent",
"percent",
self.CANVAS_W,
self.CANVAS_H,
0,
0,
)
# x=30% of 1920 = 576, y=70% of 1080 = 756
assert x == pytest.approx(576.0)
assert y == pytest.approx(756.0)
def test_custom_percent_clamped_0(self):
x, y = resolve_sticker_position(
"center",
-10.0,
-5.0,
"percent",
"percent",
self.CANVAS_W,
self.CANVAS_H,
0,
0,
)
assert x == 0.0
assert y == 0.0
def test_custom_percent_clamped_100(self):
x, y = resolve_sticker_position(
"center",
150.0,
120.0,
"percent",
"percent",
self.CANVAS_W,
self.CANVAS_H,
0,
0,
)
assert x == pytest.approx(1920.0)
assert y == pytest.approx(1080.0)
def test_custom_pixel_coordinates(self):
x, y = resolve_sticker_position(
"center",
500,
300,
"pixel",
"pixel",
self.CANVAS_W,
self.CANVAS_H,
0,
0,
)
assert x == pytest.approx(500.0)
assert y == pytest.approx(300.0)
def test_x_only_override(self):
# 只有x覆盖,y用预设
x, y = resolve_sticker_position(
"top_left",
50.0,
None,
"percent",
"percent",
self.CANVAS_W,
self.CANVAS_H,
0,
0,
)
assert x == pytest.approx(0.5 * 1920) # 50%
assert y == pytest.approx(0.05 * 1080) # 预设 top_left
def test_y_only_override(self):
x, y = resolve_sticker_position(
"top_left",
None,
50.0,
"percent",
"percent",
self.CANVAS_W,
self.CANVAS_H,
0,
0,
)
assert x == pytest.approx(0.05 * 1920)
assert y == pytest.approx(0.5 * 1080)
def test_clamped_to_canvas_with_sticker_size(self):
# 贴纸100x50,放在最右下角,不能超出画布
x, y = resolve_sticker_position(
"bottom_right",
None,
None,
"percent",
"percent",
self.CANVAS_W,
self.CANVAS_H,
100,
50,
)
assert x <= self.CANVAS_W - 100
assert y <= self.CANVAS_H - 50
def test_clamped_left_top(self):
# 很大的负偏移,被钳制在0
x, y = resolve_sticker_position(
"center",
0.0,
0.0,
"percent",
"percent",
self.CANVAS_W,
self.CANVAS_H,
200,
100,
)
# 0%位置 + 贴纸尺寸的一半偏移 = -100, -50 → 钳制到 0, 0
assert x == 0
assert y == 0
def test_zero_canvas_defaults_center(self):
x, y = resolve_sticker_position(
"top_left",
50,
50,
"pixel",
"pixel",
0,
0,
0,
0,
)
# canvas_w=0 → 回退到0.5 → 0*0.5=0
assert x == 0.0
assert y == 0.0
def test_sticker_size_zero(self):
x, y = resolve_sticker_position(
"center",
None,
None,
"percent",
"percent",
self.CANVAS_W,
self.CANVAS_H,
0,
0,
)
assert x == pytest.approx(960.0)
assert y == pytest.approx(540.0)
# ============================================================
# parse_stickers_from_config
# ============================================================
class TestParseStickersFromConfig:
def test_none_returns_empty(self):
assert parse_stickers_from_config(None) == []
def test_empty_dict_returns_empty(self):
assert parse_stickers_from_config({}) == []
def test_stickers_list(self):
config = {"stickers": [{"type": "text", "text": "hi"}, {"type": "image", "image_url": "a.png"}]}
result = parse_stickers_from_config(config)
assert len(result) == 2
assert result[0]["type"] == "text"
def test_stickers_empty_list(self):
config = {"stickers": []}
assert parse_stickers_from_config(config) == []
def test_stickers_not_list_returns_empty(self):
config = {"stickers": "not_a_list"}
assert parse_stickers_from_config(config) == []
def test_other_config_ignored(self):
config = {"other_field": "value"}
assert parse_stickers_from_config(config) == []
# ============================================================
# get_sticker_categories
# ============================================================
class TestGetStickerCategories:
def test_returns_list(self):
result = get_sticker_categories()
assert isinstance(result, list)
assert len(result) == len(STICKER_CATEGORIES)
def test_returns_copy(self):
# 修改返回值不应影响常量
result = get_sticker_categories()
result.append(("new", "新分类"))
assert len(STICKER_CATEGORIES) == len(STICKER_CATEGORIES)
# 原常量不变
assert ("new", "新分类") not in STICKER_CATEGORIES
def test_format(self):
for cat in get_sticker_categories():
assert len(cat) == 2
assert isinstance(cat[0], str)
assert isinstance(cat[1], str)