Compare commits

...

2 Commits

Author SHA1 Message Date
CI Bot 75901bed6f style: auto-format with black + isort + prettier [skip ci-format-check] 2026-07-28 09:16:45 +00:00
xiaoxia 73ffafdca6 test(wave164): filter_presets滤镜预设 +53测
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
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
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 19s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m38s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 34s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m17s
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 / Validate - Migration (alembic) (pull_request) Successful in 1m29s
CI/CD Pipeline / Frontend Unit Tests (pull_request) 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 / PR Build Web Image (pull_request) Successful in 1m48s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m49s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m44s
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 / Unit Tests (pull_request) Failing after 6m50s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 3m50s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Failing after 6s
- FilterPreset数据类:最小创建/默认值/完整参数/frozen不可变/tags独立
- 预设库:非空/ID唯一/必填字段/5个分类存在/黑白饱和度为0/原图无效果/总数
- get_filter_preset:存在ID/不存在/空串/大小写敏感/返回类型
- list_filter_presets:无筛选/5个分类筛选/无效分类/关键词搜索(名称/标签/描述)
  + 组合筛选/空关键词/返回类型
- build_ffmpeg_filter:全强度/无效预设/0强度/负强度/超100截断
  + 半强度/原图空返回/格式校验/参数分隔/各参数存在/全预设遍历
2026-07-28 16:06:26 +08:00
5 changed files with 350 additions and 4 deletions
@@ -6,7 +6,6 @@
from __future__ import annotations
# ── 单轨时间计算 ──────────────────────────────────────────────────────────────
@@ -13,7 +13,6 @@
from __future__ import annotations
from packages.domain.speed_config import (
DEFAULT_SPEED,
SpeedConfig,
+350
View File
@@ -0,0 +1,350 @@
"""filter_presets 单元测试 - wave164
覆盖:
- FilterPreset 数据类(frozen/默认值/字段)
- FILTER_PRESET_LIBRARY 预设库(数量/分类/ID唯一性)
- get_filter_preset 按ID获取
- list_filter_presets 筛选列表(分类/关键词)
- build_ffmpeg_filter 滤镜生成(强度/参数插值/边界/空值)
"""
import dataclasses
import pytest
from packages.domain.filter_presets import (
FILTER_PRESET_LIBRARY,
FilterPreset,
build_ffmpeg_filter,
get_filter_preset,
list_filter_presets,
)
# ============================================================
# FilterPreset 数据类
# ============================================================
class TestFilterPresetDataclass:
def test_minimal_creation(self):
p = FilterPreset(id="test", name="Test", category="basic")
assert p.id == "test"
assert p.name == "Test"
assert p.category == "basic"
def test_default_values(self):
p = FilterPreset(id="t", name="T", category="basic")
assert p.description == ""
assert p.tags == []
assert p.brightness == 0.0
assert p.contrast == 1.0
assert p.saturation == 1.0
assert p.gamma == 1.0
assert p.gamma_r == 1.0
assert p.gamma_g == 1.0
assert p.gamma_b == 1.0
assert p.hue == 0.0
assert p.lut_url == ""
def test_full_creation(self):
p = FilterPreset(
id="full",
name="Full",
category="cinematic",
description="test desc",
tags=["tag1", "tag2"],
brightness=0.5,
contrast=1.5,
saturation=2.0,
gamma=1.2,
gamma_r=1.3,
gamma_g=0.9,
gamma_b=0.8,
hue=30.0,
lut_url="http://lut.png",
)
assert p.description == "test desc"
assert p.tags == ["tag1", "tag2"]
assert p.brightness == 0.5
assert p.contrast == 1.5
assert p.gamma == 1.2
assert p.hue == 30.0
assert p.lut_url == "http://lut.png"
def test_frozen_immutable(self):
p = FilterPreset(id="t", name="T", category="basic")
with pytest.raises((AttributeError, dataclasses.FrozenInstanceError)):
p.brightness = 0.5 # frozen=True,不能修改
def test_tags_default_new_list(self):
# 每个实例有独立的列表
p1 = FilterPreset(id="t1", name="T1", category="basic")
p2 = FilterPreset(id="t2", name="T2", category="basic")
assert p1.tags is not p2.tags
# ============================================================
# FILTER_PRESET_LIBRARY 预设库
# ============================================================
class TestFilterPresetLibrary:
def test_library_not_empty(self):
assert len(FILTER_PRESET_LIBRARY) > 0
def test_all_ids_unique(self):
ids = [p.id for p in FILTER_PRESET_LIBRARY]
assert len(ids) == len(set(ids))
def test_all_have_required_fields(self):
for p in FILTER_PRESET_LIBRARY:
assert p.id, f"预设缺少id: {p}"
assert p.name, f"预设缺少name: {p.id}"
assert p.category, f"预设缺少category: {p.id}"
def test_categories_are_valid(self):
valid_categories = {"basic", "cinematic", "vintage", "bw", "style"}
for p in FILTER_PRESET_LIBRARY:
assert p.category in valid_categories, f"无效分类: {p.id} -> {p.category}"
def test_basic_category_exists(self):
basics = [p for p in FILTER_PRESET_LIBRARY if p.category == "basic"]
assert len(basics) >= 3
def test_cinematic_category_exists(self):
cinematic = [p for p in FILTER_PRESET_LIBRARY if p.category == "cinematic"]
assert len(cinematic) >= 1
def test_vintage_category_exists(self):
vintage = [p for p in FILTER_PRESET_LIBRARY if p.category == "vintage"]
assert len(vintage) >= 1
def test_bw_category_exists(self):
bw = [p for p in FILTER_PRESET_LIBRARY if p.category == "bw"]
assert len(bw) >= 1
# 所有黑白预设饱和度为0
for p in bw:
assert p.saturation == 0.0
def test_style_category_exists(self):
style = [p for p in FILTER_PRESET_LIBRARY if p.category == "style"]
assert len(style) >= 1
def test_none_filter_has_no_effect(self):
none_preset = get_filter_preset("filter_none")
assert none_preset is not None
assert none_preset.brightness == 0.0
assert none_preset.contrast == 1.0
assert none_preset.saturation == 1.0
assert none_preset.gamma == 1.0
def test_total_count(self):
# 至少有15个预设
assert len(FILTER_PRESET_LIBRARY) >= 15
# ============================================================
# get_filter_preset
# ============================================================
class TestGetFilterPreset:
def test_existing_id(self):
p = get_filter_preset("filter_brighten")
assert p is not None
assert p.id == "filter_brighten"
assert p.name == "明亮"
def test_nonexistent_id(self):
assert get_filter_preset("nonexistent") is None
def test_empty_id(self):
assert get_filter_preset("") is None
def test_case_sensitive(self):
# 大小写敏感
assert get_filter_preset("Filter_Brighten") is None
def test_returns_preset_object(self):
p = get_filter_preset("filter_cinematic")
assert isinstance(p, FilterPreset)
# ============================================================
# list_filter_presets
# ============================================================
class TestListFilterPresets:
def test_no_filters_returns_all(self):
result = list_filter_presets()
assert len(result) == len(FILTER_PRESET_LIBRARY)
def test_filter_by_category_basic(self):
result = list_filter_presets(category="basic")
assert len(result) > 0
for p in result:
assert p.category == "basic"
def test_filter_by_category_cinematic(self):
result = list_filter_presets(category="cinematic")
for p in result:
assert p.category == "cinematic"
def test_filter_by_category_bw(self):
result = list_filter_presets(category="bw")
for p in result:
assert p.category == "bw"
def test_filter_by_category_vintage(self):
result = list_filter_presets(category="vintage")
for p in result:
assert p.category == "vintage"
def test_filter_by_category_style(self):
result = list_filter_presets(category="style")
for p in result:
assert p.category == "style"
def test_invalid_category_returns_empty(self):
result = list_filter_presets(category="nonexistent")
assert result == []
def test_keyword_search_name(self):
result = list_filter_presets(keyword="电影")
assert len(result) >= 1
names = [p.name for p in result]
assert any("电影" in n for n in names)
def test_keyword_search_tags(self):
result = list_filter_presets(keyword="复古")
assert len(result) >= 1
# 至少有一个的标签或名称包含复古
found = any(any("复古" in t for t in p.tags) or "复古" in p.name for p in result)
assert found
def test_keyword_search_description(self):
result = list_filter_presets(keyword="青橙")
assert len(result) >= 1
def test_keyword_case_insensitive(self):
# 中文不区分大小写,用英文标签/名称试
result = list_filter_presets(keyword="FILTER")
# 搜索 filter 应该能匹配到很多(id里有但搜索name/desc/tags
# 用确定的中文关键词更可靠
assert isinstance(result, list)
def test_keyword_nonexistent_returns_empty(self):
result = list_filter_presets(keyword="zzz不存在的关键词zzz")
assert result == []
def test_category_and_keyword_combined(self):
result = list_filter_presets(category="basic", keyword="")
for p in result:
assert p.category == "basic"
assert "" in p.name or "" in p.description or any("" in t for t in p.tags)
def test_keyword_empty_returns_all(self):
result = list_filter_presets(keyword="")
assert len(result) == len(FILTER_PRESET_LIBRARY)
def test_returns_list_of_presets(self):
result = list_filter_presets()
for p in result:
assert isinstance(p, FilterPreset)
# ============================================================
# build_ffmpeg_filter
# ============================================================
class TestBuildFfmpegFilter:
def test_valid_preset_full_intensity(self):
result = build_ffmpeg_filter("filter_brighten", 100)
assert result.startswith("eq=")
assert "brightness=" in result
assert "contrast=" in result
def test_invalid_preset_returns_empty(self):
assert build_ffmpeg_filter("nonexistent", 100) == ""
def test_zero_intensity_returns_empty(self):
assert build_ffmpeg_filter("filter_brighten", 0) == ""
def test_negative_intensity_returns_empty(self):
assert build_ffmpeg_filter("filter_brighten", -10) == ""
def test_intensity_over_100_clamped(self):
# >100 按100算
r100 = build_ffmpeg_filter("filter_saturate", 100)
r200 = build_ffmpeg_filter("filter_saturate", 200)
assert r100 == r200
def test_half_intensity_half_effect(self):
full = build_ffmpeg_filter("filter_brighten", 100)
half = build_ffmpeg_filter("filter_brighten", 50)
# 半强度的brightness应该小一些
assert full != half
# 半强度仍然有效果
assert half.startswith("eq=")
def test_none_filter_returns_empty(self):
# filter_none所有参数都是默认值,应该返回空
result = build_ffmpeg_filter("filter_none", 100)
assert result == ""
def test_format_has_eq_prefix(self):
result = build_ffmpeg_filter("filter_saturate", 100)
assert result.startswith("eq=")
def test_parameters_separated_by_colon(self):
result = build_ffmpeg_filter("filter_cinematic", 100)
parts = result[len("eq=") :].split(":")
assert len(parts) >= 3 # 至少有3个参数
def test_brightness_value_format(self):
result = build_ffmpeg_filter("filter_brighten", 100)
# brightness参数应该存在且是数值
assert "brightness=0." in result or "brightness=-0." in result
def test_saturation_value_format(self):
result = build_ffmpeg_filter("filter_saturate", 100)
assert "saturation=" in result
def test_contrast_value_format(self):
result = build_ffmpeg_filter("filter_contrast", 100)
assert "contrast=" in result
def test_gamma_parameters_present(self):
result = build_ffmpeg_filter("filter_warm", 100)
# 暖色预设应该有gamma_r/gamma_b变化
assert "gamma_r=" in result or "gamma_b=" in result
def test_very_low_intensity_near_zero(self):
# 强度1%,效果接近0,但因为有brightness等可能仍然在阈值以上
result = build_ffmpeg_filter("filter_brighten", 1)
# 至少应该有一个参数(brightness * 0.01 = 0.0012 > 0.001
assert isinstance(result, str)
def test_all_presets_generate_valid_output(self):
# 每个预设在100强度下都能生成合法结果
for preset in FILTER_PRESET_LIBRARY:
result = build_ffmpeg_filter(preset.id, 100)
assert isinstance(result, str)
if result: # filter_none 可能为空
assert result.startswith("eq=")
def test_intensity_50_between_0_and_100(self):
# 验证50%强度确实在0和100之间插值
r0 = build_ffmpeg_filter("filter_saturate", 0)
r50 = build_ffmpeg_filter("filter_saturate", 50)
r100 = build_ffmpeg_filter("filter_saturate", 100)
assert r0 == ""
assert r50 != r100
assert r50 != ""
def test_hue_not_included_in_eq(self):
# hue 不在 eq 滤镜参数中(当前实现没有hue)
result = build_ffmpeg_filter("filter_cinematic", 100)
assert "hue=" not in result
@@ -6,7 +6,6 @@ domain 层纯逻辑模块,0 FFmpeg 依赖,快速轻量。
from __future__ import annotations
import dataclasses
from unittest.mock import MagicMock
import pytest
-1
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import dataclasses
import pytest
from packages.domain.transition_presets import (