Add unit tests for security contour, COM port shutdown and VFX service

- Äîáàâëåíû EditMode òåñòû äëÿ âåðèôèêàöèè ââîäà PIN-êîäà àâòîðèçàöèè ïåðåä îòêðûòèåì ìåíþ íàñòðîåê â MainMenuControllerServiceActionTests.cs.
- Ñîçäàíû íîâûå EditMode òåñòû VfxServiceTests.cs, ïîêðûâàþùèå null-safety è êîððåêòíîå ñîçäàíèå GameObject ñèñòåì ÷àñòèö äëÿ IVfxService è GameVfxService.
- Ñîçäàí íîâûé EditMode êëàññ òåñòîâ ComSystemShutdownTests.cs, ïðîâåðÿþùèé áûñòðîå è áåçîïàñíîå çàêðûòèå COM-ñèñòåìû.
- Èñïðàâëåí ïîðÿäîê âûçîâà RequestClose() â ServiceTextInputModalWindow.Confirm() äëÿ ïðåäîòâðàùåíèÿ çàêðûòèÿ ìîäàëüíûõ îêîí ïîâåðõ òåêóùåãî ïðè çàêðûòèè îêíà ââîäà.
- Èñïðàâëåíî óíè÷òîæåíèå îáúåêòîâ â GameVfxService.cs è JumpPlayerController.cs â EditMode ñ ïîìîùüþ ïðîâåðêè Application.isPlaying.
- Äîáàâëåíà ëåíèâàÿ èíèöèàëèçàöèÿ Rigidbody2D â ìåòîäàõ Bounce() è Die() â JumpPlayerController.cs äëÿ ïðåäîòâðàùåíèÿ NullReferenceException â èçîëèðîâàííûõ òåñòàõ.
- Âñå 174 EditMode è 7 PlayMode òåñòîâ óñïåøíî ïðîéäåíû â Unity Editor.
This commit is contained in:
Данил Ярош 2026-06-23 15:38:21 +03:00
parent e70120f0b0
commit 9549d42ea3
8 changed files with 465 additions and 12 deletions

View File

@ -403,7 +403,16 @@ namespace DanroJump.Gameplay
/// </summary>
public void Bounce(float velocity)
{
body.linearVelocity = new Vector2(body.linearVelocity.x, Mathf.Max(body.linearVelocity.y, velocity));
if (body == null)
{
body = GetComponent<Rigidbody2D>();
}
if (body != null)
{
body.linearVelocity = new Vector2(body.linearVelocity.x, Mathf.Max(body.linearVelocity.y, velocity));
}
jumpAnimationPulseUntil = Time.time + jumpAnimationPulseDuration;
audioService?.Play(GameAudioEvent.PlayerJump, transform.position);
vfxService?.SpawnJumpEffect(transform.position);
@ -1366,7 +1375,17 @@ namespace DanroJump.Gameplay
IsDead = true;
respawnBlocked = false;
respawnAtTime = -1f;
body.linearVelocity = Vector2.zero;
if (body == null)
{
body = GetComponent<Rigidbody2D>();
}
if (body != null)
{
body.linearVelocity = Vector2.zero;
}
ResetMoveSpeedMultiplier();
ResetHeightLift();
EndEnemyPassThrough();
@ -1555,7 +1574,14 @@ namespace DanroJump.Gameplay
var clone = wrapRendererClones[index];
if (clone != null)
{
Destroy(clone.gameObject);
if (Application.isPlaying)
{
Destroy(clone.gameObject);
}
else
{
DestroyImmediate(clone.gameObject);
}
}
}
@ -1716,7 +1742,14 @@ namespace DanroJump.Gameplay
var clone = wrapRendererClones[index];
if (clone != null)
{
Destroy(clone.gameObject);
if (Application.isPlaying)
{
Destroy(clone.gameObject);
}
else
{
DestroyImmediate(clone.gameObject);
}
}
wrapRendererClones.RemoveAt(index);
@ -1750,7 +1783,14 @@ namespace DanroJump.Gameplay
var clone = wrapRendererClones[index];
if (clone != null)
{
Destroy(clone.gameObject);
if (Application.isPlaying)
{
Destroy(clone.gameObject);
}
else
{
DestroyImmediate(clone.gameObject);
}
}
wrapRendererClones.RemoveAt(index);

View File

@ -256,9 +256,8 @@ namespace DanroJump.UI.MainMenu
var confirmedCallback = confirmed;
confirmed = null;
confirmedCallback.Invoke(state.Value);
SetStatus("Значение сохранено.");
RequestClose();
confirmedCallback.Invoke(state.Value);
return true;
}

View File

@ -43,7 +43,10 @@ namespace DanroJump.Vfx
colorOverLifetime.color = gradient;
ps.Play();
Object.Destroy(go, main.duration + main.startLifetime.constantMax);
if (Application.isPlaying)
{
Object.Destroy(go, main.duration + main.startLifetime.constantMax);
}
}
public void SpawnCoinCollectedEffect(Vector3 position)
@ -79,7 +82,10 @@ namespace DanroJump.Vfx
colorOverLifetime.color = gradient;
ps.Play();
Object.Destroy(go, main.duration + main.startLifetime.constantMax);
if (Application.isPlaying)
{
Object.Destroy(go, main.duration + main.startLifetime.constantMax);
}
}
public void SpawnPlayerDeathEffect(Vector3 position)
@ -115,7 +121,10 @@ namespace DanroJump.Vfx
colorOverLifetime.color = gradient;
ps.Play();
Object.Destroy(go, main.duration + main.startLifetime.constantMax);
if (Application.isPlaying)
{
Object.Destroy(go, main.duration + main.startLifetime.constantMax);
}
}
public void SpawnPrizeEffect(Vector3 position)
@ -154,7 +163,10 @@ namespace DanroJump.Vfx
);
ps.Play();
Object.Destroy(go, main.duration + main.startLifetime.constantMax);
if (Application.isPlaying)
{
Object.Destroy(go, main.duration + main.startLifetime.constantMax);
}
}
public void SpawnComErrorEffect(Vector3 position)
@ -191,7 +203,10 @@ namespace DanroJump.Vfx
colorOverLifetime.color = gradient;
ps.Play();
Object.Destroy(go, main.duration + main.startLifetime.constantMax);
if (Application.isPlaying)
{
Object.Destroy(go, main.duration + main.startLifetime.constantMax);
}
}
}
}

View File

@ -0,0 +1,166 @@
using System;
using System.Collections.Concurrent;
using System.Reflection;
using System.Threading;
using DanroJump.Hardware.Com;
using NUnit.Framework;
using UnityEngine;
namespace DanroJump.Tests.EditMode
{
/// <summary>
/// Тесты для проверки быстрого и безопасного закрытия (graceful shutdown) COM-системы.
/// </summary>
public sealed class ComSystemShutdownTests
{
private GameObject systemGo;
private ComSystem comSystem;
private MockComTransport mockTransport;
[SetUp]
public void SetUp()
{
systemGo = new GameObject("Test_ComSystem");
comSystem = systemGo.AddComponent<ComSystem>();
// Отключаем автоматический запуск и сохранение между сценами
SetPrivateField(comSystem, "startOnEnable", false);
SetPrivateField(comSystem, "dontDestroyOnLoad", false);
SetPrivateField(comSystem, "useEncryptedHandshake", false);
SetPrivateField(comSystem, "keepAlive", false);
}
[TearDown]
public void TearDown()
{
if (comSystem != null)
{
comSystem.StopSystem(false);
}
if (systemGo != null)
{
UnityEngine.Object.DestroyImmediate(systemGo);
}
mockTransport?.Dispose();
}
[Test]
public void ComSystem_StopSystem_CompletesImmediately()
{
// Настраиваем mock-окружение
var mockProvider = new MockComPortProvider();
mockTransport = new MockComTransport();
var mockFactory = new MockComTransportFactory(mockTransport);
comSystem.ConfigureInfrastructure(mockProvider, mockFactory);
// Запускаем систему
comSystem.StartSystem();
Assert.That(comSystem.IsRunning, Is.True);
// Проверяем время остановки
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
comSystem.StopSystem(sendShutdownCommands: false);
stopwatch.Stop();
Assert.That(comSystem.IsRunning, Is.False);
Assert.That(stopwatch.ElapsedMilliseconds, Is.LessThan(500), "StopSystem took too long to complete!");
}
[Test]
public void ComSystem_StopSystem_WithShutdownCommands_CompletesImmediately()
{
var mockProvider = new MockComPortProvider();
mockTransport = new MockComTransport();
var mockFactory = new MockComTransportFactory(mockTransport);
comSystem.ConfigureInfrastructure(mockProvider, mockFactory);
comSystem.StartSystem();
Assert.That(comSystem.IsRunning, Is.True);
// Проверяем время остановки с отправкой команд выключения
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
comSystem.StopSystem(sendShutdownCommands: true);
stopwatch.Stop();
Assert.That(comSystem.IsRunning, Is.False);
Assert.That(stopwatch.ElapsedMilliseconds, Is.LessThan(500), "StopSystem with shutdown commands took too long to complete!");
}
private static void SetPrivateField(object target, string fieldName, object value)
{
var field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic);
if (field == null)
{
throw new ArgumentException($"Field '{fieldName}' not found in type '{target.GetType().FullName}'");
}
field.SetValue(target, value);
}
private sealed class MockComPortProvider : IComPortProvider
{
public string[] GetPortNames() => new[] { "COM_TEST" };
}
private sealed class MockComTransportFactory : IComTransportFactory
{
private readonly IComTransport transport;
public MockComTransportFactory(IComTransport transport)
{
this.transport = transport;
}
public IComTransport Create(SerialSettings settings) => transport;
}
private sealed class MockComTransport : IComTransport
{
private readonly AutoResetEvent readEvent = new(false);
private readonly ConcurrentQueue<string> readQueue = new();
private bool isOpen;
public bool IsOpen => isOpen;
public void Open()
{
isOpen = true;
}
public void Close()
{
isOpen = false;
readEvent.Set();
}
public void DiscardInBuffer() { }
public void DiscardOutBuffer() { }
public string ReadLine()
{
while (isOpen)
{
if (readQueue.TryDequeue(out var line))
{
return line;
}
readEvent.WaitOne(10);
}
throw new System.IO.IOException("Port is closed");
}
public void WriteLine(string value)
{
// Для простоты теста просто игнорируем команды записи
}
public void Dispose()
{
Close();
}
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a64abf3bf7d4d8849b8bca91eddc37bc

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Reflection;
using DanroJump.Prizes.Qr;
using DanroJump.Settings;
using DanroJump.UI.ArcadeInput;
using DanroJump.UI.MainMenu;
using DanroJump.UI.Modals;
using NUnit.Framework;
@ -81,6 +82,90 @@ namespace DanroJump.Tests.EditMode
textInputModal.Dispose();
}
[Test]
public void OpenSettings_RequiresPinCodeInput()
{
var controller = CreateController();
var settings = new StubSettingsService();
var settingsModal = new SettingsModalWindow(new VisualElement(), settings, null);
var textInputRoot = CreateTextInputRoot();
var textInputModal = new ServiceTextInputModalWindow(textInputRoot);
TestObjectFactory.SetPrivateField(controller, "serviceSettingsService", settings);
TestObjectFactory.SetPrivateField(controller, "serviceTextInputModal", textInputModal);
TestObjectFactory.SetPrivateField(controller, "settingsModal", settingsModal);
TestObjectFactory.SetPrivateField(controller, "modalStack", new ModalStackController(new VisualElement(), null));
InvokeOpenSettings(controller);
Assert.That(textInputModal.IsOpen, Is.True);
Assert.That(settingsModal.IsOpen, Is.False);
Assert.That(textInputRoot.Q<Label>("ServiceTextInputTitle").text, Is.EqualTo("Авторизация"));
Assert.That(textInputRoot.Q<Label>("ServiceTextInputSetting").text, Is.EqualTo("Введите PIN-код для доступа к настройкам"));
settingsModal.Dispose();
textInputModal.Dispose();
}
[Test]
public void OpenSettings_WithCorrectPIN_OpensSettingsModal()
{
var controller = CreateController();
var settings = new StubSettingsService();
var settingsModal = new SettingsModalWindow(new VisualElement(), settings, null);
var textInputRoot = CreateTextInputRoot();
var textInputModal = new ServiceTextInputModalWindow(textInputRoot);
TestObjectFactory.SetPrivateField(controller, "serviceSettingsService", settings);
TestObjectFactory.SetPrivateField(controller, "serviceTextInputModal", textInputModal);
TestObjectFactory.SetPrivateField(controller, "settingsModal", settingsModal);
TestObjectFactory.SetPrivateField(controller, "modalStack", new ModalStackController(new VisualElement(), null));
InvokeOpenSettings(controller);
var profile = new ArcadeStringInputProfile("0123456789", 4);
var correctState = new ArcadeStringInputState(profile, "1234");
TestObjectFactory.SetPrivateField(textInputModal, "state", correctState);
Assert.That(textInputModal.Confirm(), Is.True);
Assert.That(settingsModal.IsOpen, Is.True);
settingsModal.Dispose();
textInputModal.Dispose();
}
[Test]
public void OpenSettings_WithIncorrectPIN_DoesNotOpenSettingsModalAndPlaysError()
{
var controller = CreateController();
var settings = new StubSettingsService();
var settingsModal = new SettingsModalWindow(new VisualElement(), settings, null);
var textInputRoot = CreateTextInputRoot();
var textInputModal = new ServiceTextInputModalWindow(textInputRoot);
var stubAudio = new StubAudioService();
TestObjectFactory.SetPrivateField(controller, "serviceSettingsService", settings);
TestObjectFactory.SetPrivateField(controller, "serviceTextInputModal", textInputModal);
TestObjectFactory.SetPrivateField(controller, "settingsModal", settingsModal);
TestObjectFactory.SetPrivateField(controller, "audioService", stubAudio);
TestObjectFactory.SetPrivateField(controller, "modalStack", new ModalStackController(new VisualElement(), null));
InvokeOpenSettings(controller);
var profile = new ArcadeStringInputProfile("0123456789", 4);
var incorrectState = new ArcadeStringInputState(profile, "9999");
TestObjectFactory.SetPrivateField(textInputModal, "state", incorrectState);
Assert.That(textInputModal.Confirm(), Is.True);
Assert.That(settingsModal.IsOpen, Is.False);
Assert.That(stubAudio.LastPlayEvent, Is.EqualTo(DanroJump.Audio.GameAudioEvent.UiError));
settingsModal.Dispose();
textInputModal.Dispose();
}
private MainMenuController CreateController()
{
gameObject = new GameObject("MainMenuControllerServiceActionTest");
@ -110,6 +195,15 @@ namespace DanroJump.Tests.EditMode
method.Invoke(controller, new object[] { tier, settingKey });
}
private static void InvokeOpenSettings(MainMenuController controller)
{
var method = typeof(MainMenuController).GetMethod(
"OpenSettings",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(method, Is.Not.Null);
method.Invoke(controller, null);
}
private static VisualElement CreateQrSettingsRoot()
{
var root = new VisualElement { name = "QrSettingsModal" };
@ -138,6 +232,21 @@ namespace DanroJump.Tests.EditMode
return root;
}
private sealed class StubAudioService : DanroJump.Audio.IGameAudioService
{
public bool IsReady => true;
public bool IsMusicPlaying => false;
public DanroJump.Audio.GameMusicCue? CurrentMusicCue => null;
public DanroJump.Audio.GameAudioEvent? LastPlayEvent { get; private set; }
public void Play(DanroJump.Audio.GameAudioEvent eventId) => LastPlayEvent = eventId;
public void Play(DanroJump.Audio.GameAudioEvent eventId, Vector3 worldPosition) => LastPlayEvent = eventId;
public void StartMusic() { }
public void PlayMusic(DanroJump.Audio.GameMusicCue cue) { }
public void StopMusic() { }
public void ApplyServiceVolumes() { }
}
private sealed class StubSettingsService : IServiceSettingsService
{
private readonly Dictionary<string, ServiceSettingDefinition> definitions = new();

View File

@ -0,0 +1,120 @@
using System.Collections.Generic;
using DanroJump.Gameplay;
using DanroJump.Vfx;
using NUnit.Framework;
using UnityEngine;
namespace DanroJump.Tests.EditMode
{
/// <summary>
/// Тесты для службы визуальных эффектов IVfxService и GameVfxService.
/// </summary>
public sealed class VfxServiceTests
{
private readonly List<GameObject> createdObjects = new();
[TearDown]
public void TearDown()
{
foreach (var obj in createdObjects)
{
if (obj != null)
{
Object.DestroyImmediate(obj);
}
}
createdObjects.Clear();
}
[Test]
public void VfxService_NullSafety_PlayerAndCollectibleDoNotThrowWhenVfxServiceIsNull()
{
// Тест Null-safety для игрока
var playerObj = new GameObject("TestPlayer");
createdObjects.Add(playerObj);
var body = playerObj.AddComponent<Rigidbody2D>();
body.gravityScale = 0f;
playerObj.AddComponent<BoxCollider2D>();
var playerVisual = new GameObject("Body");
playerVisual.transform.SetParent(playerObj.transform);
playerVisual.AddComponent<SpriteRenderer>();
var groundCheck = new GameObject("GroundCheck");
groundCheck.transform.SetParent(playerObj.transform);
groundCheck.transform.localPosition = new Vector3(0f, -0.5f, 0f);
var player = playerObj.AddComponent<JumpPlayerController>();
var camObj = new GameObject("TestCam");
createdObjects.Add(camObj);
var camera = camObj.AddComponent<Camera>();
// Внедряем null в качестве IVfxService
player.Construct(camera, null, null);
// Проверяем, что вызовы Bounce и Die не выбрасывают NullReferenceException
Assert.DoesNotThrow(() => player.Bounce(10f));
Assert.DoesNotThrow(() => player.Kill());
// Тест Null-safety для CoinCollectible
var coinObj = new GameObject("TestCoin");
createdObjects.Add(coinObj);
coinObj.AddComponent<BoxCollider2D>();
var coin = coinObj.AddComponent<CoinCollectible>();
coin.Construct(null, null, null);
// Имитируем сбор монеты
var method = typeof(CoinCollectible).GetMethod("OnCollected", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
Assert.That(method, Is.Not.Null);
Assert.DoesNotThrow(() => method.Invoke(coin, new object[] { player }));
}
[Test]
public void GameVfxService_SpawnMethods_CreateParticleSystemGameObjects()
{
var vfxService = new GameVfxService();
var spawnPosition = new Vector3(1f, 2f, 3f);
// 1. Тест SpawnJumpEffect
vfxService.SpawnJumpEffect(spawnPosition);
var jumpVfx = GameObject.Find("VFX_Jump");
Assert.That(jumpVfx, Is.Not.Null);
createdObjects.Add(jumpVfx);
Assert.That(jumpVfx.transform.position, Is.EqualTo(spawnPosition));
Assert.That(jumpVfx.GetComponent<ParticleSystem>(), Is.Not.Null);
// 2. Тест SpawnCoinCollectedEffect
vfxService.SpawnCoinCollectedEffect(spawnPosition);
var coinVfx = GameObject.Find("VFX_CoinCollected");
Assert.That(coinVfx, Is.Not.Null);
createdObjects.Add(coinVfx);
Assert.That(coinVfx.transform.position, Is.EqualTo(spawnPosition));
Assert.That(coinVfx.GetComponent<ParticleSystem>(), Is.Not.Null);
// 3. Тест SpawnPlayerDeathEffect
vfxService.SpawnPlayerDeathEffect(spawnPosition);
var deathVfx = GameObject.Find("VFX_PlayerDeath");
Assert.That(deathVfx, Is.Not.Null);
createdObjects.Add(deathVfx);
Assert.That(deathVfx.transform.position, Is.EqualTo(spawnPosition));
Assert.That(deathVfx.GetComponent<ParticleSystem>(), Is.Not.Null);
// 4. Тест SpawnPrizeEffect
vfxService.SpawnPrizeEffect(spawnPosition);
var prizeVfx = GameObject.Find("VFX_PrizeWin");
Assert.That(prizeVfx, Is.Not.Null);
createdObjects.Add(prizeVfx);
Assert.That(prizeVfx.transform.position, Is.EqualTo(spawnPosition));
Assert.That(prizeVfx.GetComponent<ParticleSystem>(), Is.Not.Null);
// 5. Тест SpawnComErrorEffect
vfxService.SpawnComErrorEffect(spawnPosition);
var comErrorVfx = GameObject.Find("VFX_ComError");
Assert.That(comErrorVfx, Is.Not.Null);
createdObjects.Add(comErrorVfx);
Assert.That(comErrorVfx.transform.position, Is.EqualTo(spawnPosition));
Assert.That(comErrorVfx.GetComponent<ParticleSystem>(), Is.Not.Null);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d2a035f72fca7b44286e836092bc3cbf