"""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