using System.Collections.Generic; using System.Linq; using System.Reflection; using NUnit.Framework; using TMPro; using UnityEngine; using UnityEngine.UI; using XGame.VirtualInput; public sealed class VirtualActionButtonTests { private readonly List _roots = new List(); [TearDown] public void TearDown() { foreach (GameObject root in _roots) { if (root != null) { Object.DestroyImmediate(root); } } _roots.Clear(); } [Test] public void PointerTap_EmitsDownThenTap() { VirtualActionButton button = CreateButton(ActionButtonId.Primary); List events = Capture(button); button.HandlePointerDownForTest(new Vector2(100f, 100f), 1, Time.unscaledTime); button.HandlePointerUpForTest(new Vector2(102f, 100f), 1, 0.1f); Assert.That(events.Select(e => e.Phase), Is.EqualTo(new[] { ActionButtonPhase.Down, ActionButtonPhase.Tap })); Assert.That(events.Last().ButtonId, Is.EqualTo(ActionButtonId.Primary)); } [Test] public void PointerUpInsideButtonWithoutMeaningfulDrag_EmitsTapEvenNearButtonEdge() { VirtualActionButton button = CreateButton(ActionButtonId.Skill1); List events = Capture(button); button.HandlePointerDownForTest(new Vector2(140f, 100f), 1, 0f); button.HandlePointerUpForTest(new Vector2(141f, 100f), 1, 0.1f); Assert.That(events.Select(e => e.Phase), Is.EqualTo(new[] { ActionButtonPhase.Down, ActionButtonPhase.Tap })); } [Test] public void PointerDragPastThreshold_EmitsReleaseWithDirection() { VirtualActionButton button = CreateButton(ActionButtonId.Skill1); List events = Capture(button); button.HandlePointerDownForTest(new Vector2(100f, 100f), 1, 0f); button.HandleDragForTest(new Vector2(160f, 100f), 1, 0.1f); button.HandlePointerUpForTest(new Vector2(160f, 100f), 1, 0.2f); ActionButtonEvent release = events.Last(); Assert.That(release.Phase, Is.EqualTo(ActionButtonPhase.Release)); Assert.That(release.Direction.x, Is.EqualTo(1f).Within(0.001f)); Assert.That(release.Direction.y, Is.EqualTo(0f).Within(0.001f)); Assert.That(release.Distance01, Is.GreaterThan(0.7f)); } [Test] public void PointerUpInsideCancelArea_EmitsCancel() { VirtualActionButton button = CreateButton(ActionButtonId.Skill2); RectTransform cancelArea = CreateRect("CancelArea", new Vector2(200f, 100f), new Vector2(80f, 80f)); button.SetCancelArea(cancelArea); List events = Capture(button); button.HandlePointerDownForTest(new Vector2(100f, 100f), 1, 0f); button.HandleDragForTest(new Vector2(200f, 100f), 1, 0.1f); button.HandlePointerUpForTest(new Vector2(200f, 100f), 1, 0.2f); Assert.That(events.Last().Phase, Is.EqualTo(ActionButtonPhase.Cancel)); Assert.That(events.Last().IsCanceled, Is.True); } [Test] public void Cooldown_EmitsBlockedOnly() { VirtualActionButton button = CreateButton(ActionButtonId.Skill3); List events = Capture(button); button.SetCooldown(3f, 5f); button.HandlePointerDownForTest(new Vector2(100f, 100f), 1, 0f); Assert.That(events.Select(e => e.Phase), Is.EqualTo(new[] { ActionButtonPhase.Blocked })); } [Test] public void ChargesZero_EmitsBlockedAndShowsChargeText() { VirtualActionButton button = CreateButton(ActionButtonId.Skill4, out _, out TMP_Text chargeText); List events = Capture(button); button.SetCharges(0); button.HandlePointerDownForTest(new Vector2(100f, 100f), 1, 0f); Assert.That(events.Single().Phase, Is.EqualTo(ActionButtonPhase.Blocked)); Assert.That(chargeText.text, Is.EqualTo("0")); } [Test] public void BindNodes_FindsPrefabChildrenWithButtonPrefix() { VirtualActionButton button = CreateButton(ActionButtonId.Skill1, "Skill1", out _, out TMP_Text chargeText); button.SetCharges(2); Assert.That(chargeText.text, Is.EqualTo("2")); Assert.That(chargeText.gameObject.activeSelf, Is.True); } [Test] public void Hold_EmitsHoldStartTickAndEnd() { VirtualActionButton button = CreateButton(ActionButtonId.Primary); List events = Capture(button); button.HandlePointerDownForTest(new Vector2(100f, 100f), 1, 0f); button.HandleDragForTest(new Vector2(102f, 100f), 1, 0.4f); button.HandleDragForTest(new Vector2(102f, 100f), 1, 0.51f); button.HandlePointerUpForTest(new Vector2(102f, 100f), 1, 0.6f); Assert.That(events.Select(e => e.Phase), Does.Contain(ActionButtonPhase.HoldStart)); Assert.That(events.Select(e => e.Phase), Does.Contain(ActionButtonPhase.HoldTick)); Assert.That(events.Select(e => e.Phase), Does.Contain(ActionButtonPhase.HoldEnd)); } [Test] public void Update_EmitsHoldWhilePointerIsStationary() { VirtualActionButton button = CreateButton(ActionButtonId.Primary); List events = Capture(button); float startedAt = Time.unscaledTime - 0.4f; button.HandlePointerDownForTest(new Vector2(100f, 100f), 1, startedAt); InvokePrivate(button, "Update"); Assert.That(events.Select(e => e.Phase), Does.Contain(ActionButtonPhase.HoldStart)); } [Test] public void OnDisable_CancelsActivePointer() { VirtualActionButton button = CreateButton(ActionButtonId.Skill2); List events = Capture(button); button.HandlePointerDownForTest(new Vector2(100f, 100f), 1, 0f); InvokePrivate(button, "OnDisable"); Assert.That(events.Select(e => e.Phase), Is.EqualTo(new[] { ActionButtonPhase.Down, ActionButtonPhase.Cancel })); } private VirtualActionButton CreateButton(ActionButtonId id) { return CreateButton(id, out _, out _); } private VirtualActionButton CreateButton(ActionButtonId id, out Image cooldownMask, out TMP_Text chargeText) { return CreateButton(id, null, out cooldownMask, out chargeText); } private VirtualActionButton CreateButton(ActionButtonId id, string childPrefix, out Image cooldownMask, out TMP_Text chargeText) { RectTransform rect = CreateRect("Btn_" + id, new Vector2(100f, 100f), new Vector2(100f, 100f)); string prefix = string.IsNullOrEmpty(childPrefix) ? string.Empty : childPrefix + "_"; new GameObject(prefix + "Icon", typeof(RectTransform), typeof(Image)).transform.SetParent(rect, false); cooldownMask = new GameObject(prefix + "CooldownMask", typeof(RectTransform), typeof(Image)).GetComponent(); cooldownMask.transform.SetParent(rect, false); chargeText = new GameObject(prefix + "ChargeText", typeof(RectTransform), typeof(TextMeshProUGUI)).GetComponent(); chargeText.transform.SetParent(rect, false); new GameObject(prefix + "CooldownText", typeof(RectTransform), typeof(TextMeshProUGUI)).transform.SetParent(rect, false); new GameObject(prefix + "AimIndicator", typeof(RectTransform)).transform.SetParent(rect, false); VirtualActionButton button = rect.gameObject.AddComponent(); button.ButtonId = id; SetPrivateField(button, "screenCenterOverride", new Vector2(100f, 100f)); SetPrivateField(button, "screenRadiusOverride", 50f); InvokePrivate(button, "Awake"); return button; } private RectTransform CreateRect(string name, Vector2 position, Vector2 size) { GameObject go = new GameObject(name, typeof(RectTransform)); _roots.Add(go); RectTransform rect = go.GetComponent(); rect.position = position; rect.sizeDelta = size; return rect; } private static List Capture(VirtualActionButton button) { var events = new List(); button.ButtonEvent += events.Add; return events; } private static void SetPrivateField(object target, string name, object value) { FieldInfo field = target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(field, Is.Not.Null); field.SetValue(target, value); } private static void InvokePrivate(object target, string name, params object[] args) { MethodInfo method = target.GetType().GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(method, Is.Not.Null); method.Invoke(target, args); } }