9a25eb6642
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
489 lines
17 KiB
Python
Executable File
489 lines
17 KiB
Python
Executable File
"""audio_track_config 模块单测 — 纯逻辑,无外部依赖."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from packages.domain.audio_track_config import (
|
|
ALLOWED_AUDIO_EXTENSIONS,
|
|
DEFAULT_VOLUMES,
|
|
MAX_AUDIO_TRACKS,
|
|
TRACK_TYPE_AMBIENT,
|
|
TRACK_TYPE_BGM,
|
|
TRACK_TYPE_MAIN,
|
|
TRACK_TYPE_SFX,
|
|
TRACK_TYPE_VOICEOVER,
|
|
AudioTrack,
|
|
MultiTrackMixConfig,
|
|
clamp_volume,
|
|
is_valid_audio_extension,
|
|
)
|
|
|
|
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestConstants:
|
|
def test_track_type_constants(self):
|
|
assert TRACK_TYPE_MAIN == "main"
|
|
assert TRACK_TYPE_BGM == "bgm"
|
|
assert TRACK_TYPE_VOICEOVER == "voiceover"
|
|
assert TRACK_TYPE_SFX == "sfx"
|
|
assert TRACK_TYPE_AMBIENT == "ambient"
|
|
|
|
def test_default_volumes_keys(self):
|
|
assert set(DEFAULT_VOLUMES.keys()) == {
|
|
TRACK_TYPE_MAIN,
|
|
TRACK_TYPE_BGM,
|
|
TRACK_TYPE_VOICEOVER,
|
|
TRACK_TYPE_SFX,
|
|
TRACK_TYPE_AMBIENT,
|
|
}
|
|
|
|
def test_default_volumes_values(self):
|
|
assert DEFAULT_VOLUMES[TRACK_TYPE_MAIN] == 1.0
|
|
assert DEFAULT_VOLUMES[TRACK_TYPE_BGM] == 0.3
|
|
assert DEFAULT_VOLUMES[TRACK_TYPE_VOICEOVER] == 1.0
|
|
assert DEFAULT_VOLUMES[TRACK_TYPE_SFX] == 0.7
|
|
assert DEFAULT_VOLUMES[TRACK_TYPE_AMBIENT] == 0.2
|
|
|
|
def test_max_audio_tracks(self):
|
|
assert MAX_AUDIO_TRACKS == 8
|
|
|
|
def test_allowed_extensions(self):
|
|
assert ".mp3" in ALLOWED_AUDIO_EXTENSIONS
|
|
assert ".wav" in ALLOWED_AUDIO_EXTENSIONS
|
|
assert ".aac" in ALLOWED_AUDIO_EXTENSIONS
|
|
assert ".ogg" in ALLOWED_AUDIO_EXTENSIONS
|
|
assert ".flac" in ALLOWED_AUDIO_EXTENSIONS
|
|
assert ".m4a" in ALLOWED_AUDIO_EXTENSIONS
|
|
assert ".wma" in ALLOWED_AUDIO_EXTENSIONS
|
|
assert ".mp4" not in ALLOWED_AUDIO_EXTENSIONS
|
|
assert ".txt" not in ALLOWED_AUDIO_EXTENSIONS
|
|
|
|
|
|
# ── AudioTrack 默认值 ────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestAudioTrackDefaults:
|
|
def test_default_values(self):
|
|
track = AudioTrack()
|
|
assert track.track_id == ""
|
|
assert track.track_type == TRACK_TYPE_SFX
|
|
assert track.audio_path == ""
|
|
assert track.volume == 1.0
|
|
assert track.fade_in == 0.0
|
|
assert track.fade_out == 0.0
|
|
assert track.start_time == 0.0
|
|
assert track.duration == 0.0
|
|
assert track.enabled is True
|
|
|
|
|
|
# ── AudioTrack.from_dict ─────────────────────────────────────────────────────
|
|
|
|
|
|
class TestAudioTrackFromDict:
|
|
def test_full_fields(self):
|
|
track = AudioTrack.from_dict(
|
|
{
|
|
"track_id": "t1",
|
|
"track_type": "bgm",
|
|
"audio_path": "/tmp/a.mp3",
|
|
"volume": 0.5,
|
|
"fade_in": 1.5,
|
|
"fade_out": 2.0,
|
|
"start_time": 3.0,
|
|
"duration": 10.0,
|
|
"enabled": True,
|
|
}
|
|
)
|
|
assert track.track_id == "t1"
|
|
assert track.track_type == "bgm"
|
|
assert track.audio_path == "/tmp/a.mp3"
|
|
assert track.volume == 0.5
|
|
assert track.fade_in == 1.5
|
|
assert track.fade_out == 2.0
|
|
assert track.start_time == 3.0
|
|
assert track.duration == 10.0
|
|
assert track.enabled is True
|
|
|
|
def test_empty_dict_defaults(self):
|
|
track = AudioTrack.from_dict({})
|
|
assert track.track_type == TRACK_TYPE_SFX
|
|
assert track.volume == DEFAULT_VOLUMES[TRACK_TYPE_SFX]
|
|
assert track.fade_in == 0.0
|
|
assert track.enabled is True
|
|
|
|
def test_volume_clamped_to_zero(self):
|
|
track = AudioTrack.from_dict({"volume": -0.5})
|
|
assert track.volume == 0.0
|
|
|
|
def test_volume_clamped_to_two(self):
|
|
track = AudioTrack.from_dict({"volume": 3.0})
|
|
assert track.volume == 2.0
|
|
|
|
def test_invalid_volume_fallback_to_default(self):
|
|
track = AudioTrack.from_dict({"track_type": "bgm", "volume": "invalid"})
|
|
assert track.volume == DEFAULT_VOLUMES[TRACK_TYPE_BGM]
|
|
|
|
def test_negative_fade_clamped_to_zero(self):
|
|
track = AudioTrack.from_dict({"fade_in": -1.0, "fade_out": -2.0})
|
|
assert track.fade_in == 0.0
|
|
assert track.fade_out == 0.0
|
|
|
|
def test_invalid_fade_fallback(self):
|
|
track = AudioTrack.from_dict({"fade_in": "bad", "fade_out": "bad"})
|
|
assert track.fade_in == 0.0
|
|
assert track.fade_out == 0.0
|
|
|
|
def test_negative_start_time_clamped(self):
|
|
track = AudioTrack.from_dict({"start_time": -5.0})
|
|
assert track.start_time == 0.0
|
|
|
|
def test_invalid_start_time_fallback(self):
|
|
track = AudioTrack.from_dict({"start_time": "bad"})
|
|
assert track.start_time == 0.0
|
|
|
|
def test_negative_duration_clamped(self):
|
|
track = AudioTrack.from_dict({"duration": -3.0})
|
|
assert track.duration == 0.0
|
|
|
|
def test_invalid_duration_fallback(self):
|
|
track = AudioTrack.from_dict({"duration": "bad"})
|
|
assert track.duration == 0.0
|
|
|
|
def test_enabled_false(self):
|
|
track = AudioTrack.from_dict({"enabled": False})
|
|
assert track.enabled is False
|
|
|
|
def test_bgm_default_volume(self):
|
|
track = AudioTrack.from_dict({"track_type": "bgm"})
|
|
assert track.volume == 0.3
|
|
|
|
def test_unknown_track_type_default_volume(self):
|
|
track = AudioTrack.from_dict({"track_type": "unknown_type"})
|
|
assert track.volume == 1.0
|
|
|
|
def test_string_numeric_values(self):
|
|
track = AudioTrack.from_dict(
|
|
{
|
|
"volume": "0.8",
|
|
"fade_in": "1.0",
|
|
"start_time": "2.5",
|
|
}
|
|
)
|
|
assert track.volume == 0.8
|
|
assert track.fade_in == 1.0
|
|
assert track.start_time == 2.5
|
|
|
|
|
|
# ── AudioTrack.validate ──────────────────────────────────────────────────────
|
|
|
|
|
|
class TestAudioTrackValidate:
|
|
def test_valid_track(self):
|
|
track = AudioTrack(audio_path="/tmp/a.mp3")
|
|
ok, err = track.validate()
|
|
assert ok is True
|
|
assert err == ""
|
|
|
|
def test_empty_audio_path_invalid(self):
|
|
track = AudioTrack(audio_path="")
|
|
ok, err = track.validate()
|
|
assert ok is False
|
|
assert "audio_path" in err
|
|
|
|
def test_volume_below_zero_invalid(self):
|
|
# from_dict 会 clamp,但直接构造可以测试
|
|
track = AudioTrack(audio_path="a.mp3")
|
|
object.__setattr__(track, "volume", -0.1)
|
|
ok, err = track.validate()
|
|
assert ok is False
|
|
assert "volume" in err
|
|
|
|
def test_volume_above_two_invalid(self):
|
|
track = AudioTrack(audio_path="a.mp3")
|
|
object.__setattr__(track, "volume", 2.1)
|
|
ok, err = track.validate()
|
|
assert ok is False
|
|
assert "volume" in err
|
|
|
|
def test_volume_boundary_zero_valid(self):
|
|
track = AudioTrack(audio_path="a.mp3")
|
|
object.__setattr__(track, "volume", 0.0)
|
|
ok, _ = track.validate()
|
|
assert ok is True
|
|
|
|
def test_volume_boundary_two_valid(self):
|
|
track = AudioTrack(audio_path="a.mp3")
|
|
object.__setattr__(track, "volume", 2.0)
|
|
ok, _ = track.validate()
|
|
assert ok is True
|
|
|
|
def test_negative_fade_in_invalid(self):
|
|
track = AudioTrack(audio_path="a.mp3")
|
|
object.__setattr__(track, "fade_in", -1.0)
|
|
ok, err = track.validate()
|
|
assert ok is False
|
|
assert "fade_in" in err
|
|
|
|
def test_negative_fade_out_invalid(self):
|
|
track = AudioTrack(audio_path="a.mp3")
|
|
object.__setattr__(track, "fade_out", -1.0)
|
|
ok, err = track.validate()
|
|
assert ok is False
|
|
assert "fade_out" in err
|
|
|
|
def test_negative_start_time_invalid(self):
|
|
track = AudioTrack(audio_path="a.mp3")
|
|
object.__setattr__(track, "start_time", -0.5)
|
|
ok, err = track.validate()
|
|
assert ok is False
|
|
assert "start_time" in err
|
|
|
|
def test_negative_duration_invalid(self):
|
|
track = AudioTrack(audio_path="a.mp3")
|
|
object.__setattr__(track, "duration", -1.0)
|
|
ok, err = track.validate()
|
|
assert ok is False
|
|
assert "duration" in err
|
|
|
|
|
|
# ── AudioTrack.is_effective ──────────────────────────────────────────────────
|
|
|
|
|
|
class TestAudioTrackIsEffective:
|
|
def test_enabled_with_path(self):
|
|
track = AudioTrack(audio_path="/tmp/a.mp3", enabled=True)
|
|
assert track.is_effective is True
|
|
|
|
def test_disabled_with_path(self):
|
|
track = AudioTrack(audio_path="/tmp/a.mp3", enabled=False)
|
|
assert track.is_effective is False
|
|
|
|
def test_enabled_empty_path(self):
|
|
track = AudioTrack(audio_path="", enabled=True)
|
|
assert track.is_effective is False
|
|
|
|
def test_disabled_empty_path(self):
|
|
track = AudioTrack(audio_path="", enabled=False)
|
|
assert track.is_effective is False
|
|
|
|
|
|
# ── MultiTrackMixConfig.from_config_dict ─────────────────────────────────────
|
|
|
|
|
|
class TestMultiTrackMixConfigFromDict:
|
|
def test_none_config_empty(self):
|
|
cfg = MultiTrackMixConfig.from_config_dict(None)
|
|
assert cfg.tracks == []
|
|
assert cfg.master_volume == 1.0
|
|
assert cfg.normalize is True
|
|
assert cfg.max_output_volume == 1.5
|
|
|
|
def test_empty_dict_defaults(self):
|
|
cfg = MultiTrackMixConfig.from_config_dict({})
|
|
assert cfg.tracks == []
|
|
assert cfg.master_volume == 1.0
|
|
assert cfg.normalize is True
|
|
|
|
def test_single_track(self):
|
|
cfg = MultiTrackMixConfig.from_config_dict(
|
|
{
|
|
"tracks": [{"audio_path": "a.mp3", "track_type": "bgm", "volume": 0.5}],
|
|
}
|
|
)
|
|
assert len(cfg.tracks) == 1
|
|
assert cfg.tracks[0].audio_path == "a.mp3"
|
|
assert cfg.tracks[0].track_type == "bgm"
|
|
assert cfg.tracks[0].volume == 0.5
|
|
|
|
def test_multiple_tracks(self):
|
|
cfg = MultiTrackMixConfig.from_config_dict(
|
|
{
|
|
"tracks": [
|
|
{"audio_path": "main.wav", "track_type": "main"},
|
|
{"audio_path": "bgm.mp3", "track_type": "bgm"},
|
|
{"audio_path": "sfx.wav", "track_type": "sfx"},
|
|
],
|
|
}
|
|
)
|
|
assert len(cfg.tracks) == 3
|
|
types = [t.track_type for t in cfg.tracks]
|
|
assert "main" in types
|
|
assert "bgm" in types
|
|
assert "sfx" in types
|
|
|
|
def test_skip_disabled_track(self):
|
|
cfg = MultiTrackMixConfig.from_config_dict(
|
|
{
|
|
"tracks": [
|
|
{"audio_path": "a.mp3", "enabled": True},
|
|
{"audio_path": "b.mp3", "enabled": False},
|
|
],
|
|
}
|
|
)
|
|
assert len(cfg.tracks) == 1
|
|
assert cfg.tracks[0].audio_path == "a.mp3"
|
|
|
|
def test_skip_missing_audio_path(self):
|
|
cfg = MultiTrackMixConfig.from_config_dict(
|
|
{
|
|
"tracks": [
|
|
{"audio_path": "a.mp3"},
|
|
{"track_type": "bgm"}, # 无audio_path
|
|
],
|
|
}
|
|
)
|
|
assert len(cfg.tracks) == 1
|
|
|
|
def test_invalid_track_skipped(self):
|
|
cfg = MultiTrackMixConfig.from_config_dict(
|
|
{
|
|
"tracks": [
|
|
{"audio_path": "a.mp3"},
|
|
"not_a_dict",
|
|
{"audio_path": 123, "volume": "bad"}, # 类型不对
|
|
],
|
|
}
|
|
)
|
|
# 第二个不是dict跳过,第三个audio_path会被转成字符串"123"
|
|
# 但 track_type 非dict的话在 isinstance(t, dict) 判断就被跳过
|
|
assert len(cfg.tracks) >= 1
|
|
|
|
def test_master_volume_clamped(self):
|
|
cfg = MultiTrackMixConfig.from_config_dict(
|
|
{
|
|
"tracks": [],
|
|
"master_volume": 3.0,
|
|
}
|
|
)
|
|
assert cfg.master_volume == 2.0
|
|
|
|
def test_master_volume_negative_clamped(self):
|
|
cfg = MultiTrackMixConfig.from_config_dict(
|
|
{
|
|
"master_volume": -1.0,
|
|
}
|
|
)
|
|
assert cfg.master_volume == 0.0
|
|
|
|
def test_invalid_master_volume_fallback(self):
|
|
cfg = MultiTrackMixConfig.from_config_dict(
|
|
{
|
|
"master_volume": "invalid",
|
|
}
|
|
)
|
|
assert cfg.master_volume == 1.0
|
|
|
|
def test_normalize_false(self):
|
|
cfg = MultiTrackMixConfig.from_config_dict({"normalize": False})
|
|
assert cfg.normalize is False
|
|
|
|
def test_custom_max_output_volume(self):
|
|
cfg = MultiTrackMixConfig.from_config_dict({"max_output_volume": 2.0})
|
|
assert cfg.max_output_volume == 2.0
|
|
|
|
def test_invalid_max_output_volume_fallback(self):
|
|
cfg = MultiTrackMixConfig.from_config_dict({"max_output_volume": "bad"})
|
|
assert cfg.max_output_volume == 1.5
|
|
|
|
def test_not_dict_config(self):
|
|
cfg = MultiTrackMixConfig.from_config_dict("not a dict")
|
|
assert cfg.tracks == []
|
|
assert cfg.master_volume == 1.0
|
|
|
|
|
|
# ── MultiTrackMixConfig 属性 ─────────────────────────────────────────────────
|
|
|
|
|
|
class TestMultiTrackMixConfigProperties:
|
|
def test_has_effect_empty(self):
|
|
cfg = MultiTrackMixConfig()
|
|
assert cfg.has_effect is False
|
|
|
|
def test_has_effect_with_tracks(self):
|
|
cfg = MultiTrackMixConfig(
|
|
tracks=[
|
|
AudioTrack(audio_path="a.mp3", enabled=True),
|
|
]
|
|
)
|
|
assert cfg.has_effect is True
|
|
|
|
def test_has_effect_all_disabled(self):
|
|
cfg = MultiTrackMixConfig(
|
|
tracks=[
|
|
AudioTrack(audio_path="a.mp3", enabled=False),
|
|
]
|
|
)
|
|
assert cfg.has_effect is False
|
|
|
|
def test_effective_track_count(self):
|
|
cfg = MultiTrackMixConfig(
|
|
tracks=[
|
|
AudioTrack(audio_path="a.mp3", enabled=True),
|
|
AudioTrack(audio_path="b.mp3", enabled=False),
|
|
AudioTrack(audio_path="c.mp3", enabled=True),
|
|
AudioTrack(audio_path="", enabled=True),
|
|
]
|
|
)
|
|
assert cfg.effective_track_count == 2
|
|
|
|
def test_main_tracks(self):
|
|
cfg = MultiTrackMixConfig(
|
|
tracks=[
|
|
AudioTrack(audio_path="m1.mp3", track_type=TRACK_TYPE_MAIN),
|
|
AudioTrack(audio_path="b1.mp3", track_type=TRACK_TYPE_BGM),
|
|
AudioTrack(audio_path="m2.mp3", track_type=TRACK_TYPE_MAIN, enabled=False),
|
|
]
|
|
)
|
|
mains = cfg.main_tracks
|
|
assert len(mains) == 1
|
|
assert mains[0].audio_path == "m1.mp3"
|
|
|
|
def test_bgm_tracks(self):
|
|
cfg = MultiTrackMixConfig(
|
|
tracks=[
|
|
AudioTrack(audio_path="b1.mp3", track_type=TRACK_TYPE_BGM),
|
|
AudioTrack(audio_path="b2.mp3", track_type=TRACK_TYPE_BGM),
|
|
AudioTrack(audio_path="v1.mp3", track_type=TRACK_TYPE_VOICEOVER),
|
|
]
|
|
)
|
|
assert len(cfg.bgm_tracks) == 2
|
|
|
|
|
|
# ── 工具函数 ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestUtils:
|
|
@pytest.mark.parametrize(
|
|
"name,expected",
|
|
[
|
|
("song.mp3", True),
|
|
("audio.WAV", True),
|
|
("track.m4a", True),
|
|
("video.mp4", False),
|
|
("text.txt", False),
|
|
("", False),
|
|
("/path/to/music.flac", True),
|
|
("sound.OGG", True),
|
|
],
|
|
)
|
|
def test_is_valid_audio_extension(self, name, expected):
|
|
assert is_valid_audio_extension(name) is expected
|
|
|
|
def test_clamp_volume_within_range(self):
|
|
assert clamp_volume(1.0) == 1.0
|
|
assert clamp_volume(0.0) == 0.0
|
|
assert clamp_volume(2.0) == 2.0
|
|
|
|
def test_clamp_volume_below_min(self):
|
|
assert clamp_volume(-0.5) == 0.0
|
|
|
|
def test_clamp_volume_above_max(self):
|
|
assert clamp_volume(3.0) == 2.0
|
|
|
|
def test_clamp_volume_custom_range(self):
|
|
assert clamp_volume(0.5, 0.2, 0.8) == 0.5
|
|
assert clamp_volume(0.1, 0.2, 0.8) == 0.2
|
|
assert clamp_volume(1.0, 0.2, 0.8) == 0.8
|