Danro-Jump/Assets/Tests/EditMode/Editor/GameAudioServiceTests.cs
Danil Iarosh 839a68aef1 Improve VFX, UI overlays, session statistics, COM reliability, audio mixer, and expand architecture guides
Подробный список изменений:
- Перенесены эффекты частиц из C#-кода в готовые префабы-ассеты (Vfx Prefabs) в папке Assets/Resources/Vfx/ с автоматической генерацией материалов в Editor.
- Исправлены ошибки duration на играющих системах частиц и розовые текстуры в GameVfxService.cs.
- Исправлена навигация и фокус кнопок на оверлеях ContinueGameOverlay и PrizeResultOverlay с автоматическим подсвечиванием кнопок лимонно-зеленым цветом при фокусе и проигрыванием звука.
- Добавлена проверка настроек в GameplayHudController.cs для скрытия ContinueGameOverlay, если продолжение игры отключено.
- Расширен сбор статистики: создана структура GameplaySessionRecord, добавлена история до 50 раундов в PrizeSessionStateService и JsonPrizeSessionStateRepository, выведен расчет средних показателей и список 5 последних раундов в сервисной статистике.
- Повышена надежность COM-транспорта: добавлен sleep (80мс) в Dispose для graceful shutdown потоков, реализован цикл переподключения до 10 попыток при обрыве связи.
- Интегрированы настройки громкости с микшером: изменен шаг изменения громкости на 5% (0.05) для удобства, добавлен статус-чек и логи в ApplyServiceVolumes, расширены юнит-тесты на GetFloat параметры.
- Созданы новые архитектурные гайды по аппаратному COM-транспорту и призовой экономике автомата в папке GUIDES/architecture/.
- Оптимизирован и обновлен RAG-индекс проекта.
2026-07-03 15:04:08 +03:00

367 lines
14 KiB
C#

using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using DanroJump.Audio;
using DanroJump.Settings;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace DanroJump.Tests.EditMode
{
public sealed class GameAudioServiceTests
{
private const string AudioSettingsPath = "Assets/Audio/Settings/GameAudioSettings.asset";
private const string ProjectContextPath = "Assets/Resources/ProjectContext.prefab";
[Test]
public void LinearToDecibels_MapsUnitySliderValuesToMixerDecibels()
{
Assert.That(GameAudioService.LinearToDecibels(1f), Is.EqualTo(0f).Within(0.001f));
Assert.That(GameAudioService.LinearToDecibels(0.5f), Is.EqualTo(-6.0206f).Within(0.001f));
Assert.That(GameAudioService.LinearToDecibels(0f), Is.EqualTo(GameAudioService.MutedDecibels));
}
[Test]
public void Settings_ReturnsBindingForConfiguredEvent()
{
var settings = ScriptableObject.CreateInstance<GameAudioSettings>();
var binding = new GameAudioClipBinding();
var clip = AudioClip.Create("test-coin", 8, 1, 8000, false);
TestObjectFactory.SetPrivateField(binding, "eventId", GameAudioEvent.CoinCollected);
TestObjectFactory.SetPrivateField(binding, "clips", new[] { clip });
TestObjectFactory.SetPrivateField(settings, "eventBindings", new List<GameAudioClipBinding> { binding });
try
{
Assert.That(settings.TryGetBinding(GameAudioEvent.CoinCollected, out var resolved), Is.True);
Assert.That(resolved, Is.SameAs(binding));
Assert.That(resolved.GetRandomClip(), Is.SameAs(clip));
}
finally
{
Object.DestroyImmediate(clip);
Object.DestroyImmediate(settings);
}
}
[Test]
public void Settings_ResolvesConfiguredMusicCue()
{
var settings = ScriptableObject.CreateInstance<GameAudioSettings>();
var track = new GameMusicTrack();
var clip = AudioClip.Create("test-gameplay-music", 8, 1, 8000, false);
TestObjectFactory.SetPrivateField(track, "cue", GameMusicCue.Gameplay);
TestObjectFactory.SetPrivateField(track, "clip", clip);
TestObjectFactory.SetPrivateField(settings, "defaultMusicCue", GameMusicCue.Gameplay);
TestObjectFactory.SetPrivateField(settings, "musicTracks", new List<GameMusicTrack> { track });
try
{
Assert.That(settings.TryGetMusicTrack(GameMusicCue.Gameplay, out var resolved), Is.True);
Assert.That(resolved, Is.SameAs(track));
Assert.That(settings.TryGetDefaultMusicTrack(out var defaultTrack), Is.True);
Assert.That(defaultTrack, Is.SameAs(track));
}
finally
{
Object.DestroyImmediate(clip);
Object.DestroyImmediate(settings);
}
}
[Test]
public void ProjectContext_ReferencesGameAudioSettings()
{
var settings = AssetDatabase.LoadAssetAtPath<GameAudioSettings>(AudioSettingsPath);
var projectContext = AssetDatabase.LoadAssetAtPath<GameObject>(ProjectContextPath);
Assert.That(settings, Is.Not.Null);
Assert.That(settings.Mixer, Is.Not.Null);
Assert.That(settings.MusicGroup, Is.Not.Null);
Assert.That(projectContext, Is.Not.Null);
var installer = projectContext.GetComponentInChildren<AppServicesInstaller>(true);
var field = typeof(AppServicesInstaller).GetField(
"audioSettings",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(installer, Is.Not.Null);
Assert.That(field?.GetValue(installer), Is.SameAs(settings));
}
[Test]
public void AudioSettings_HasAudibleClipBindingForEveryGameEvent()
{
var settings = AssetDatabase.LoadAssetAtPath<GameAudioSettings>(AudioSettingsPath);
Assert.That(settings, Is.Not.Null);
foreach (GameAudioEvent eventId in System.Enum.GetValues(typeof(GameAudioEvent)))
{
Assert.That(settings.TryGetBinding(eventId, out var binding), Is.True, $"{eventId} has no binding.");
Assert.That(binding.HasClip, Is.True, $"{eventId} has no assigned clip.");
Assert.That(binding.GetRandomClip(), Is.Not.Null, $"{eventId} cannot resolve a playable clip.");
}
}
[Test]
public void AudioMixer_RoutesNonMusicGroupsThroughSfxGroup()
{
var settings = AssetDatabase.LoadAssetAtPath<GameAudioSettings>(AudioSettingsPath);
Assert.That(settings, Is.Not.Null);
Assert.That(settings.Mixer, Is.Not.Null);
Assert.That(settings.MusicGroup, Is.Not.Null);
var masterChildren = GetMixerGroupChildren(settings.Mixer, "Master");
var sfxChildren = GetMixerGroupChildren(settings.Mixer, "SFX");
Assert.That(masterChildren, Is.EquivalentTo(new[] { "Music", "SFX" }));
Assert.That(sfxChildren, Is.EquivalentTo(new[] { "UI", "Gameplay", "Ambience" }));
}
[Test]
public void ApplyServiceVolumes_UsesConfiguredExposedMixerParameters()
{
var settings = AssetDatabase.LoadAssetAtPath<GameAudioSettings>(AudioSettingsPath);
var serviceSettings = new StubReadOnlySettings(0.25f, 0.5f);
var service = new GameAudioService(settings, serviceSettings);
Assert.That(HasExposedParameter(settings.Mixer, GameAudioService.MusicVolumeParameter), Is.True);
Assert.That(HasExposedParameter(settings.Mixer, GameAudioService.SoundsVolumeParameter), Is.True);
Assert.DoesNotThrow(() => service.ApplyServiceVolumes());
}
[Test]
public void Initialize_CreatesAudioSourcesAndDisposeDestroysRoot()
{
var settings = AssetDatabase.LoadAssetAtPath<GameAudioSettings>(AudioSettingsPath);
var service = new GameAudioService(settings, new StubReadOnlySettings(1f, 1f));
var rootField = typeof(GameAudioService).GetField(
"root",
BindingFlags.Instance | BindingFlags.NonPublic);
service.Initialize();
service.Play(GameAudioEvent.UiSubmit);
service.Play(GameAudioEvent.CoinCollected, new Vector3(1f, 2f, 0f));
var root = (GameObject)rootField.GetValue(service);
Assert.That(root, Is.Not.Null);
Assert.That(root.name, Is.EqualTo("Game Audio Service"));
Assert.That(root.GetComponentsInChildren<AudioSource>(true), Has.Length.GreaterThanOrEqualTo(11));
service.Dispose();
Assert.That(root == null, Is.True);
Assert.That(rootField.GetValue(service), Is.Null);
}
[Test]
public void AudioEmitter_PlaysNamedAnimationEvent()
{
var root = new GameObject("Audio Emitter Test");
var service = new RecordingAudioService();
var emitter = root.AddComponent<GameAudioEmitter>();
try
{
emitter.Construct(service);
emitter.PlayByName(nameof(GameAudioEvent.EnemyAttack));
emitter.PlayGlobalByName(nameof(GameAudioEvent.PlayerHurt));
Assert.That(service.PlayedEvents, Is.EqualTo(new[]
{
GameAudioEvent.EnemyAttack,
GameAudioEvent.PlayerHurt
}));
Assert.That(service.PlayedSpatialFlags, Is.EqualTo(new[] { true, false }));
}
finally
{
Object.DestroyImmediate(root);
}
}
[Test]
public void SettingChanged_ReappliesMixerVolumesWithoutThrowing()
{
var settings = AssetDatabase.LoadAssetAtPath<GameAudioSettings>(AudioSettingsPath);
var serviceSettings = new StubReadOnlySettings(1f, 1f);
var service = new GameAudioService(settings, serviceSettings);
try
{
service.Initialize();
serviceSettings.SetVolumes(0.2f, 0.4f);
serviceSettings.RaiseSettingChanged(ServiceSettingsKeys.MusicVolume);
serviceSettings.RaiseSettingChanged(ServiceSettingsKeys.SoundsVolume);
Assert.That(HasExposedParameter(settings.Mixer, GameAudioService.MusicVolumeParameter), Is.True);
Assert.That(HasExposedParameter(settings.Mixer, GameAudioService.SoundsVolumeParameter), Is.True);
settings.Mixer.GetFloat(GameAudioService.MusicVolumeParameter, out float musicDb);
settings.Mixer.GetFloat(GameAudioService.SoundsVolumeParameter, out float soundsDb);
Assert.That(musicDb, Is.EqualTo(GameAudioService.LinearToDecibels(0.2f)).Within(0.001f));
Assert.That(soundsDb, Is.EqualTo(GameAudioService.LinearToDecibels(0.4f)).Within(0.001f));
}
finally
{
service.Dispose();
settings.Mixer.SetFloat(GameAudioService.MusicVolumeParameter, 0f);
settings.Mixer.SetFloat(GameAudioService.SoundsVolumeParameter, 0f);
}
}
private static IReadOnlyList<string> GetMixerGroupChildren(UnityEngine.Audio.AudioMixer mixer, string groupName)
{
var group = mixer.FindMatchingGroups(groupName).First(candidate => candidate.name == groupName);
var childrenProperty = group.GetType().GetProperty(
"children",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
var children = (System.Array)childrenProperty.GetValue(group);
return children
.Cast<UnityEngine.Audio.AudioMixerGroup>()
.Select(child => child.name)
.ToArray();
}
private static bool HasExposedParameter(UnityEngine.Audio.AudioMixer mixer, string parameterName)
{
var controllerType = typeof(Editor).Assembly.GetType("UnityEditor.Audio.AudioMixerController");
var exposedProperty = controllerType.GetProperty(
"exposedParameters",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
var exposed = (System.Array)exposedProperty.GetValue(mixer);
var elementType = exposed.GetType().GetElementType();
var nameField = elementType.GetField(
"name",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
return exposed
.Cast<object>()
.Any(item => string.Equals((string)nameField.GetValue(item), parameterName, System.StringComparison.Ordinal));
}
private sealed class StubReadOnlySettings : IReadOnlyServiceSettings
{
private float musicVolume;
private float soundsVolume;
public StubReadOnlySettings(float musicVolume, float soundsVolume)
{
this.musicVolume = musicVolume;
this.soundsVolume = soundsVolume;
}
public ServiceSettingsDatabase Database => null;
public event System.Action SettingsLoaded;
public event System.Action SettingsSaved;
public event System.Action<string, ServiceSettingValue> SettingChanged;
public void SetVolumes(float musicVolume, float soundsVolume)
{
this.musicVolume = musicVolume;
this.soundsVolume = soundsVolume;
}
public void RaiseSettingChanged(string key)
{
SettingChanged?.Invoke(key, ServiceSettingValue.Float(GetFloat(key)));
}
public bool TryGetDefinition(string key, out ServiceSettingDefinition definition)
{
definition = null;
return false;
}
public IReadOnlyDictionary<string, ServiceSettingValue> Snapshot()
{
return new Dictionary<string, ServiceSettingValue>
{
[ServiceSettingsKeys.MusicVolume] = ServiceSettingValue.Float(musicVolume),
[ServiceSettingsKeys.SoundsVolume] = ServiceSettingValue.Float(soundsVolume)
};
}
public ServiceSettingValue Get(string key)
{
return ServiceSettingValue.Float(GetFloat(key));
}
public bool GetBool(string key)
{
return false;
}
public int GetInt(string key)
{
return 0;
}
public float GetFloat(string key)
{
return key == ServiceSettingsKeys.MusicVolume
? musicVolume
: key == ServiceSettingsKeys.SoundsVolume
? soundsVolume
: 0f;
}
public string GetString(string key)
{
return string.Empty;
}
public int GetOptionIndex(string key)
{
return 0;
}
}
private sealed class RecordingAudioService : IGameAudioService
{
public readonly List<GameAudioEvent> PlayedEvents = new();
public readonly List<bool> PlayedSpatialFlags = new();
public bool IsReady => true;
public bool IsMusicPlaying => false;
public GameMusicCue? CurrentMusicCue => null;
public void Play(GameAudioEvent eventId)
{
PlayedEvents.Add(eventId);
PlayedSpatialFlags.Add(false);
}
public void Play(GameAudioEvent eventId, Vector3 worldPosition)
{
PlayedEvents.Add(eventId);
PlayedSpatialFlags.Add(true);
}
public void StartMusic()
{
}
public void PlayMusic(GameMusicCue cue)
{
}
public void StopMusic()
{
}
public void ApplyServiceVolumes()
{
}
}
}
}