diff --git a/README.md b/README.md index 3269cec..1c23177 100644 --- a/README.md +++ b/README.md @@ -50,18 +50,20 @@ Configurations in `MonkeyConfig`: - **Lifetime**: Running time - **DelayMillis**: Delay time between operations - **SecondsToErrorForNoInteractiveComponent**: Seconds to determine that an error has occurred when an object that can be interacted with does not exist -- **TouchAndHoldDelayMillis**: Delay time for touch-and-hold - **Random**: Random generator - **Logger**: Logger - **Gizmos**: Show Gizmos on `GameView` during running monkey test if true - **Screenshots**: Take screenshots during running the monkey test if set a `ScreenshotOptions` instance. + - **Directory**: Directory to save screenshots. If omitted, the directory specified by command line argument "-testHelperScreenshotDirectory" is used. If the command line argument is also omitted, `Application.persistentDataPath` + "/TestHelper/Screenshots/" is used. + - **FilenameStrategy**: Strategy for file paths of screenshot images. Default is test case name and four digit sequential number. + - **SuperSize**: The factor to increase resolution with. Default is 1. + - **StereoCaptureMode**: The eye texture to capture when stereo rendering is enabled. Default is `LeftEye`. -Configurations in `ScreenshotOptions`: +More customize for your project: -- **Directory**: Directory to save screenshots. If omitted, the directory specified by command line argument "-testHelperScreenshotDirectory" is used. If the command line argument is also omitted, `Application.persistentDataPath` + "/TestHelper/Screenshots/" is used. -- **FilenameStrategy**: Strategy for file paths of screenshot images. Default is test case name and four digit sequential number. -- **SuperSize**: The factor to increase resolution with. Default is 1. -- **StereoCaptureMode**: The eye texture to capture when stereo rendering is enabled. Default is `LeftEye`. +- **IsReachable**: Function returns the `GameObject` is reachable from user or not. Default implementation is using Raycaster and includes ScreenPointStrategy (GetScreenPoint function). +- **IsInteractable**: Function returns the `Component` is interactable or not. The default implementation is support for standard Unity UI (uGUI) components. +- **Operators**: Operators that the monkey invokes. Default is ClickOperator, ClickAndHoldOperator, and TextInputOperator. There is support for standard Unity UI (uGUI) components. ### Annotations for Monkey's behavior @@ -151,9 +153,13 @@ public class MyIntegrationTest } ``` -#### InteractiveComponent.CreateInteractableComponent +#### InteractiveComponent and Operators -Returns new InteractableComponent instance from GameObject. If GameObject is not interactable so, return null. +##### InteractiveComponent +Returns new `InteractableComponent` instance from GameObject. If GameObject is not interactable so, return null. + +##### Operators +Operators implements `IOperator` interface. It has `OperateAsync` method that operates on the component. Usage: @@ -168,10 +174,11 @@ public class MyIntegrationTest public void MyTestMethod() { var finder = new GameObjectFinder(); - var button = await finder.FindByNameAsync("Button", interactable: true); + var button = await finder.FindByNameAsync("StartButton", interactable: true); var interactableComponent = InteractiveComponent.CreateInteractableComponent(button); - interactableComponent.Click(); + var clickOperator = interactableComponent.GetOperatorsByType(OperatorType.Click).First(); + clickOperator.OperateAsync(interactableComponent.component); } } ``` @@ -183,8 +190,10 @@ Returns interactable uGUI components. Usage: ```csharp +using System.Linq; using NUnit.Framework; using TestHelper.Monkey; +using TestHelper.Monkey.Operators; [TestFixture] public class MyIntegrationTest @@ -193,6 +202,10 @@ public class MyIntegrationTest public void MyTestMethod() { var components = InteractiveComponentCollector.FindInteractableComponents(); + + var firstComponent = components.First(); + var clickAndHoldOperator = firstComponent.GetOperatorsByType(OperatorType.ClickAndHold).First(); + await clickAndHoldOperator.OperateAsync(firstComponent.component); } } ``` @@ -208,6 +221,7 @@ Usage: using System.Linq; using NUnit.Framework; using TestHelper.Monkey; +using TestHelper.Monkey.Operators; [TestFixture] public class MyIntegrationTest @@ -215,11 +229,11 @@ public class MyIntegrationTest [Test] public void MyTestMethod() { - var component = InteractiveComponentCollector.FindReachableInteractableComponents() - .First(); + var components = InteractiveComponentCollector.FindReachableInteractableComponents(); - Assume.That(component.CanClick(), Is.True); - component.Click(); + var firstComponent = components.First(); + var textInputOperator = firstComponent.GetOperatorsByType(OperatorType.TextInput).First(); + textInputOperator.OperateAsync(firstComponent.component); // input random text } } ``` diff --git a/Runtime/DefaultStrategies/DefaultReachableStrategy.cs b/Runtime/DefaultStrategies/DefaultReachableStrategy.cs index 8837706..001ec7b 100644 --- a/Runtime/DefaultStrategies/DefaultReachableStrategy.cs +++ b/Runtime/DefaultStrategies/DefaultReachableStrategy.cs @@ -14,25 +14,22 @@ namespace TestHelper.Monkey.DefaultStrategies /// public static class DefaultReachableStrategy { + private static Func GetScreenPoint => DefaultScreenPointStrategy.GetScreenPoint; + /// /// Make sure the GameObject is reachable from user. /// Hit test using raycaster /// /// - /// The function returns the screen position where raycast for the found GameObject. - /// Default is DefaultScreenPointStrategy.GetScreenPoint. /// Specify if avoid GC memory allocation /// Specify if avoid GC memory allocation /// True if this GameObject is reachable from user public static bool IsReachable(GameObject gameObject, - Func getScreenPoint = null, PointerEventData eventData = null, List results = null) { - getScreenPoint = getScreenPoint ?? DefaultScreenPointStrategy.GetScreenPoint; - eventData = eventData ?? new PointerEventData(EventSystem.current); - eventData.position = getScreenPoint.Invoke(gameObject); + eventData.position = GetScreenPoint.Invoke(gameObject); results = results ?? new List(); results.Clear(); diff --git a/Runtime/GameObjectFinder.cs b/Runtime/GameObjectFinder.cs index 4da1f2d..91a22e5 100644 --- a/Runtime/GameObjectFinder.cs +++ b/Runtime/GameObjectFinder.cs @@ -8,7 +8,6 @@ using Cysharp.Threading.Tasks; using TestHelper.Monkey.DefaultStrategies; using TestHelper.Monkey.Extensions; -using TestHelper.Monkey.ScreenPointStrategies; using UnityEngine; using UnityEngine.EventSystems; @@ -20,11 +19,7 @@ namespace TestHelper.Monkey public class GameObjectFinder { private readonly double _timeoutSeconds; - private readonly Func _getScreenPoint; - - private readonly Func, PointerEventData, List, bool> - _isReachable; - + private readonly Func, bool> _isReachable; private readonly Func _isComponentInteractable; private readonly PointerEventData _eventData = new PointerEventData(EventSystem.current); private readonly List _results = new List(); @@ -33,19 +28,15 @@ private readonly Func, PointerEventData, L /// Constructor. /// /// Seconds to wait until GameObject appear. - /// The function returns the screen position where raycast for the found GameObject. - /// Default is DefaultScreenPointStrategy.GetScreenPoint. /// The function returns the GameObject is reachable from user or not. /// Default is DefaultReachableStrategy.IsReachable. /// The function returns the Component is interactable or not. /// Default is DefaultComponentInteractableStrategy.IsInteractable. public GameObjectFinder(double timeoutSeconds = 1.0d, - Func getScreenPoint = null, - Func, PointerEventData, List, bool> isReachable = null, + Func, bool> isReachable = null, Func isComponentInteractable = null) { _timeoutSeconds = timeoutSeconds; - _getScreenPoint = getScreenPoint ?? DefaultScreenPointStrategy.GetScreenPoint; _isReachable = isReachable ?? DefaultReachableStrategy.IsReachable; _isComponentInteractable = isComponentInteractable ?? DefaultComponentInteractableStrategy.IsInteractable; } @@ -70,7 +61,7 @@ private enum Reason if (reachable) { - if (!_isReachable.Invoke(foundObject, _getScreenPoint, _eventData, _results)) + if (!_isReachable.Invoke(foundObject, _eventData, _results)) { return (null, Reason.NotReachable); } diff --git a/Runtime/Hints/InteractiveComponentHint.cs b/Runtime/Hints/InteractiveComponentHint.cs index 322bcc8..ac71a17 100644 --- a/Runtime/Hints/InteractiveComponentHint.cs +++ b/Runtime/Hints/InteractiveComponentHint.cs @@ -98,7 +98,7 @@ private void Refresh() { Clear(); - var interactiveComponentCollector = new InteractiveComponentCollector(getScreenPoint: GetScreenPoint); + var interactiveComponentCollector = new InteractiveComponentCollector(); foreach (var component in interactiveComponentCollector.FindInteractableComponents()) { var dst = component.IsReachable() diff --git a/Runtime/InteractiveComponent.cs b/Runtime/InteractiveComponent.cs index 7cccf48..b53bc4a 100644 --- a/Runtime/InteractiveComponent.cs +++ b/Runtime/InteractiveComponent.cs @@ -4,13 +4,12 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Threading; using Cysharp.Threading.Tasks; using TestHelper.Monkey.DefaultStrategies; using TestHelper.Monkey.Extensions; using TestHelper.Monkey.Operators; -using TestHelper.Monkey.Random; -using TestHelper.Monkey.ScreenPointStrategies; using UnityEngine; using UnityEngine.EventSystems; @@ -40,46 +39,40 @@ public class InteractiveComponent [SuppressMessage("ReSharper", "InconsistentNaming")] public GameObject gameObject => component.gameObject; - private readonly Func _getScreenPoint; - - private readonly Func, PointerEventData, List, bool> - _isReachable; - + private readonly Func, bool> _isReachable; + private readonly IEnumerable _operators; private readonly PointerEventData _eventData = new PointerEventData(EventSystem.current); private readonly List _results = new List(); - internal InteractiveComponent(MonoBehaviour component, - Func getScreenPoint = null, - Func, PointerEventData, List, bool> isReachable = null) + private InteractiveComponent(MonoBehaviour component, + Func, bool> isReachable = null, + IEnumerable operators = null) { this.component = component; - _getScreenPoint = getScreenPoint ?? DefaultScreenPointStrategy.GetScreenPoint; _isReachable = isReachable ?? DefaultReachableStrategy.IsReachable; + _operators = operators; } /// /// Create InteractableComponent instance from MonoBehaviour. /// /// - /// The function returns the screen position where raycast for the found GameObject. - /// Default is DefaultScreenPointStrategy.GetScreenPoint. /// The function returns the GameObject is reachable from user or not. /// Default is DefaultReachableStrategy.IsReachable. /// The function returns the Component is interactable or not. /// Default is DefaultComponentInteractableStrategy.IsInteractable. + /// All available operators in autopilot/tests. Usually defined in MonkeyConfig /// Returns new InteractableComponent instance from MonoBehaviour. If MonoBehaviour is not interactable so, return null. public static InteractiveComponent CreateInteractableComponent(MonoBehaviour component, - Func getScreenPoint = null, - Func, PointerEventData, List, bool> isReachable = null, - Func isComponentInteractable = null) + Func, bool> isReachable = null, + Func isComponentInteractable = null, + IEnumerable operators = null) { - getScreenPoint = getScreenPoint ?? DefaultScreenPointStrategy.GetScreenPoint; - isReachable = isReachable ?? DefaultReachableStrategy.IsReachable; isComponentInteractable = isComponentInteractable ?? DefaultComponentInteractableStrategy.IsInteractable; if (isComponentInteractable.Invoke(component)) { - return new InteractiveComponent(component, getScreenPoint, isReachable); + return new InteractiveComponent(component, isReachable, operators); } throw new ArgumentException("Component is not interactable."); @@ -89,28 +82,25 @@ public static InteractiveComponent CreateInteractableComponent(MonoBehaviour com /// Create InteractableComponent instance from GameObject. /// /// - /// The function returns the screen position where raycast for the found GameObject. - /// Default is DefaultScreenPointStrategy.GetScreenPoint. /// The function returns the GameObject is reachable from user or not. /// Default is DefaultReachableStrategy.IsReachable. /// The function returns the Component is interactable or not. /// Default is DefaultComponentInteractableStrategy.IsInteractable. + /// All available operators in autopilot/tests. Usually defined in MonkeyConfig /// Returns new InteractableComponent instance from GameObject. If GameObject is not interactable so, return null. [Obsolete("Obsolete due to non-deterministic behavior when GameObject has multiple interactable components.")] public static InteractiveComponent CreateInteractableComponent(GameObject gameObject, - Func getScreenPoint = null, - Func, PointerEventData, List, bool> isReachable = null, - Func isComponentInteractable = null) + Func, bool> isReachable = null, + Func isComponentInteractable = null, + IEnumerable operators = null) { - getScreenPoint = getScreenPoint ?? DefaultScreenPointStrategy.GetScreenPoint; - isReachable = isReachable ?? DefaultReachableStrategy.IsReachable; isComponentInteractable = isComponentInteractable ?? DefaultComponentInteractableStrategy.IsInteractable; foreach (var component in gameObject.GetComponents()) { if (isComponentInteractable.Invoke(component)) { - return new InteractiveComponent(component, getScreenPoint, isReachable); + return new InteractiveComponent(component, isReachable, operators); } } @@ -137,60 +127,99 @@ public bool IsReallyInteractiveFromUser(Func screenPointStr /// true: this object can control by user public bool IsReachable() { - return _isReachable.Invoke(gameObject, _getScreenPoint, _eventData, _results); + return _isReachable.Invoke(gameObject, _eventData, _results); } /// - /// Check inner component can receive click event + /// Returns the operators available to this component. /// - /// true: Can click - public bool CanClick() => ClickOperator.CanClick(component); + /// Available operators + public IEnumerable GetOperators() + { + if (_operators == null || !_operators.Any()) + { + throw new NotSupportedException("Operators are not set."); + } + + return _operators.Where(iOperator => iOperator.CanOperate(component)); + } /// - /// Click inner component + /// Returns the operators that specify types and are available to this component. /// - public void Click() => ClickOperator.Click(component, _getScreenPoint); + /// Operator type + /// Available operators + public IEnumerable GetOperatorsByType(OperatorType type) + { + if (_operators == null || !_operators.Any()) + { + throw new NotSupportedException("Operators are not set."); + } + + return _operators.Where(iOperator => iOperator.Type == type && iOperator.CanOperate(component)); + } /// - /// Check inner component can receive tap (click) event + /// Check component can receive click (tap) event. /// - /// true: Can tap - public bool CanTap() => ClickOperator.CanClick(component); + [Obsolete] + public bool CanClick() => GetOperatorsByType(OperatorType.Click).Any(); /// - /// Tap (click) inner component + /// Click component. /// - public void Tap() => ClickOperator.Click(component, _getScreenPoint); + [Obsolete] + public void Click() => GetOperatorsByType(OperatorType.Click).First().OperateAsync(component); + + [Obsolete] + public bool CanTap() => CanClick(); + + [Obsolete] + public void Tap() => Click(); /// - /// Check inner component can receive touch-and-hold event + /// Check component can receive click (tap) and hold event. /// - /// true: Can touch-and-hold - public bool CanTouchAndHold() => TouchAndHoldOperator.CanTouchAndHold(component); + [Obsolete] + public bool CanClickAndHold() => GetOperatorsByType(OperatorType.ClickAndHold).Any(); /// - /// Touch-and-hold inner component + /// Click (touch) and hold component. /// - /// Delay time between down to up - /// Task cancellation token - public async UniTask TouchAndHold(int delayMillis = 1000, CancellationToken cancellationToken = default) - => await TouchAndHoldOperator.TouchAndHold(component, _getScreenPoint, delayMillis, cancellationToken); + [Obsolete] + public async UniTask ClickAndHold(CancellationToken cancellationToken = default) + { + var clickAndHoldOperator = GetOperatorsByType(OperatorType.ClickAndHold).First(); + await clickAndHoldOperator.OperateAsync(component, cancellationToken); + } + + [Obsolete] + public bool CanTouchAndHold() => CanClickAndHold(); + + [Obsolete] + public async UniTask TouchAndHold(CancellationToken cancellationToken = default) => + await ClickAndHold(cancellationToken); /// - /// Check inner component can input text + /// Check component can input text. /// - /// true: Can click - public bool CanTextInput() => TextInputOperator.CanTextInput(component); + [Obsolete] + public bool CanTextInput() => GetOperatorsByType(OperatorType.TextInput).Any(); /// - /// Input random text that is randomly generated by + /// Input random text. /// - /// Random string generation parameters - /// Random string generator - public void TextInput(Func randomStringParams, - IRandomString randomString) => - TextInputOperator.Input(component, randomStringParams, randomString); + [Obsolete] + public void TextInput() => GetOperatorsByType(OperatorType.TextInput).First().OperateAsync(component); - // TODO: drag, swipe, flick, etc... + /// + /// Input specified text. + /// + [Obsolete] + public void TextInput(string text) + { + var textInputOperator = (ITextInputOperator)GetOperatorsByType(OperatorType.TextInput).First(); + textInputOperator.OperateAsync(component, text); + } } } diff --git a/Runtime/InteractiveComponentCollector.cs b/Runtime/InteractiveComponentCollector.cs index ee63c76..3641308 100644 --- a/Runtime/InteractiveComponentCollector.cs +++ b/Runtime/InteractiveComponentCollector.cs @@ -4,7 +4,7 @@ using System; using System.Collections.Generic; using TestHelper.Monkey.DefaultStrategies; -using TestHelper.Monkey.ScreenPointStrategies; +using TestHelper.Monkey.Operators; using UnityEngine; using UnityEngine.EventSystems; using Object = UnityEngine.Object; @@ -17,32 +17,28 @@ namespace TestHelper.Monkey // TODO: Rename to InteractableComponentsFinder public class InteractiveComponentCollector { - private readonly Func _getScreenPoint; - - private readonly Func, PointerEventData, List, bool> - _isReachable; - + private readonly Func, bool> _isReachable; private readonly Func _isInteractable; + private readonly IEnumerable _operators; private readonly PointerEventData _eventData = new PointerEventData(EventSystem.current); private readonly List _results = new List(); /// /// Constructor. /// - /// The function returns the screen position where raycast for the found GameObject. - /// Default is DefaultScreenPointStrategy.GetScreenPoint. /// The function returns the GameObject is reachable from user or not. /// Default is DefaultReachableStrategy.IsReachable. /// The function returns the Component is interactable or not. /// Default is DefaultComponentInteractableStrategy.IsInteractable. + /// All available operators in autopilot/tests. Usually defined in MonkeyConfig public InteractiveComponentCollector( - Func getScreenPoint = null, - Func, PointerEventData, List, bool> isReachable = null, - Func isInteractable = null) + Func, bool> isReachable = null, + Func isInteractable = null, + IEnumerable operators = null) { - _getScreenPoint = getScreenPoint ?? DefaultScreenPointStrategy.GetScreenPoint; _isReachable = isReachable ?? DefaultReachableStrategy.IsReachable; _isInteractable = isInteractable ?? DefaultComponentInteractableStrategy.IsInteractable; + _operators = operators; } /// @@ -59,8 +55,9 @@ public IEnumerable FindInteractableComponents() if (_isInteractable.Invoke(component)) { yield return InteractiveComponent.CreateInteractableComponent(component, - _getScreenPoint, - _isReachable); + _isReachable, + _isInteractable, + _operators); } } } @@ -83,7 +80,7 @@ public IEnumerable FindReachableInteractableComponents() { foreach (var interactiveComponent in FindInteractableComponents()) { - if (_isReachable.Invoke(interactiveComponent.gameObject, _getScreenPoint, _eventData, _results)) + if (_isReachable.Invoke(interactiveComponent.gameObject, _eventData, _results)) { yield return interactiveComponent; } @@ -94,7 +91,7 @@ public IEnumerable FindReachableInteractableComponents() public static IEnumerable FindReallyInteractiveComponents( Func screenPointStrategy) { - var instance = new InteractiveComponentCollector(getScreenPoint: screenPointStrategy); + var instance = new InteractiveComponentCollector(); return instance.FindReachableInteractableComponents(); } diff --git a/Runtime/Monkey.cs b/Runtime/Monkey.cs index 3f3d19f..1a5759b 100644 --- a/Runtime/Monkey.cs +++ b/Runtime/Monkey.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2023 Koji Hasegawa. +// Copyright (c) 2023-2024 Koji Hasegawa. // This software is released under the MIT License. using System; @@ -6,6 +6,8 @@ using System.Linq; using System.Threading; using Cysharp.Threading.Tasks; +using TestHelper.Monkey.Annotations; +using TestHelper.Monkey.Operators; using TestHelper.Random; using TestHelper.RuntimeInternals; using UnityEngine; @@ -45,9 +47,9 @@ public static async UniTask Run(MonkeyConfig config, CancellationToken cancellat } var interactiveComponentCollector = new InteractiveComponentCollector( - getScreenPoint: config.ScreenPointStrategy, isReachable: config.IsReachable, - isInteractable: config.IsInteractable); + isInteractable: config.IsInteractable, + operators: config.Operators); config.Logger.Log($"Using {config.Random}"); @@ -91,14 +93,14 @@ private class CoroutineRunner : MonoBehaviour /// /// Cancellation token /// - public static async UniTask RunStep(MonkeyConfig config, - InteractiveComponentCollector interactiveComponentCollector, CancellationToken cancellationToken = default) + public static async UniTask RunStep( + MonkeyConfig config, + InteractiveComponentCollector interactiveComponentCollector, + CancellationToken cancellationToken = default) { - var components = interactiveComponentCollector - .FindInteractableComponents() - .ToList(); - var component = Lottery(ref components, config.Random); - if (component == null) + var operators = GetOperators(interactiveComponentCollector); + var (selectedComponent, selectedOperator) = LotteryOperator(operators.ToList(), config.Random); + if (selectedComponent == null || selectedOperator == null) { return false; } @@ -107,7 +109,7 @@ public static async UniTask RunStep(MonkeyConfig config, { if (s_coroutineRunner == null || (bool)s_coroutineRunner == false) { - s_coroutineRunner = new GameObject().AddComponent(); + s_coroutineRunner = new GameObject("CoroutineRunner").AddComponent(); } await ScreenshotHelper.TakeScreenshot( @@ -119,76 +121,35 @@ await ScreenshotHelper.TakeScreenshot( .ToUniTask(s_coroutineRunner); } - await DoOperation(component, config, cancellationToken); + config.Logger.Log($"{selectedOperator} operates to {selectedComponent.gameObject.name}"); + await selectedOperator.OperateAsync(selectedComponent.component, cancellationToken); return true; } - internal static InteractiveComponent Lottery( - ref List components, - IRandom random) + internal static IEnumerable<(InteractiveComponent, IOperator)> GetOperators( + InteractiveComponentCollector interactiveComponentCollector) { - if (components == null || components.Count == 0) - { - return null; - } + var components = interactiveComponentCollector.FindInteractableComponents() + .Where(x => !x.gameObject.TryGetComponent(typeof(IgnoreAnnotation), out _)); + return components.SelectMany(x => x.GetOperators(), (x, o) => (x, o)); + } - while (true) + internal static (InteractiveComponent, IOperator) LotteryOperator( + List<(InteractiveComponent, IOperator)> operators, + IRandom random) + { + while (operators.Count > 0) { - if (components.Count == 0) + var (selectedComponent, selectedOperator) = operators[random.Next(operators.Count)]; + if (selectedComponent.IsReachable()) { - return null; + return (selectedComponent, selectedOperator); } - var next = components[random.Next(components.Count)]; - if (next.IsReachable() && GetCanOperations(next).Any()) - { - return next; - } - - components.Remove(next); + operators.Remove((selectedComponent, selectedOperator)); } - } - private enum SupportOperation - { - Click, - TouchAndHold, - TextInput, - } - - private static IEnumerable GetCanOperations(InteractiveComponent component) - { - if (component.CanClick()) yield return SupportOperation.Click; - if (component.CanTouchAndHold()) yield return SupportOperation.TouchAndHold; - if (component.CanTextInput()) yield return SupportOperation.TextInput; - } - - internal static async UniTask DoOperation( - InteractiveComponent component, - MonkeyConfig config, - CancellationToken cancellationToken = default - ) - { - var operations = GetCanOperations(component).ToArray(); - var operation = operations[config.Random.Next(operations.Length)]; - config.Logger.Log($"Do operation {component.gameObject.name} {operation.ToString()}"); - switch (operation) - { - case SupportOperation.Click: - component.Click(); - break; - case SupportOperation.TouchAndHold: - await component.TouchAndHold( - config.TouchAndHoldDelayMillis, - cancellationToken - ); - break; - case SupportOperation.TextInput: - component.TextInput(config.RandomStringParametersStrategy, config.RandomString); - break; - default: - throw new IndexOutOfRangeException(); - } + return (null, null); } } } diff --git a/Runtime/MonkeyConfig.cs b/Runtime/MonkeyConfig.cs index a07778b..dc846f1 100644 --- a/Runtime/MonkeyConfig.cs +++ b/Runtime/MonkeyConfig.cs @@ -1,11 +1,10 @@ -// Copyright (c) 2023 Koji Hasegawa. +// Copyright (c) 2023-2024 Koji Hasegawa. // This software is released under the MIT License. using System; using System.Collections.Generic; using TestHelper.Monkey.DefaultStrategies; -using TestHelper.Monkey.Random; -using TestHelper.Monkey.ScreenPointStrategies; +using TestHelper.Monkey.Operators; using TestHelper.Random; using UnityEngine; using UnityEngine.EventSystems; @@ -32,60 +31,46 @@ public class MonkeyConfig /// public int SecondsToErrorForNoInteractiveComponent { get; set; } = 5; - /// - /// Delay time for touch-and-hold - /// - public int TouchAndHoldDelayMillis { get; set; } = 1000; - /// /// Random number generator /// public IRandom Random { get; set; } = new RandomWrapper(); /// - /// Random string generator + /// Logger /// - public IRandomString RandomString { get; set; } = new RandomStringImpl(new RandomWrapper()); + public ILogger Logger { get; set; } = Debug.unityLogger; /// - /// Logger + /// Show Gizmos on GameView during running monkey test if true /// - public ILogger Logger { get; set; } = Debug.unityLogger; + public bool Gizmos { get; set; } /// - /// Function returns the screen position where monkey operators operate on for the specified gameObject + /// Take screenshots during running the monkey test if set a ScreenshotOptions instance. /// - public Func ScreenPointStrategy { get; set; } = DefaultScreenPointStrategy.GetScreenPoint; + public ScreenshotOptions Screenshots { get; set; } /// /// Function returns the GameObject is reachable from user or not. + /// This function is include ScreenPointStrategy (GetScreenPoint function). /// - public Func, PointerEventData, List, bool> + public Func, bool> IsReachable { get; set; } = DefaultReachableStrategy.IsReachable; /// /// Function returns the Component is interactable or not. /// - public Func - IsInteractable { get; set; } = DefaultComponentInteractableStrategy.IsInteractable; + public Func IsInteractable { get; set; } = DefaultComponentInteractableStrategy.IsInteractable; /// - /// Function returns the random string generation parameters - /// - public Func RandomStringParametersStrategy { get; set; } = - DefaultRandomStringParameterGen; - - private static RandomStringParameters DefaultRandomStringParameterGen(GameObject _) => - RandomStringParameters.Default; - - /// - /// Show Gizmos on GameView during running monkey test if true - /// - public bool Gizmos { get; set; } = false; - - /// - /// Take screenshots during running the monkey test if set a ScreenshotOptions instance. + /// Operators that the monkey invokes. /// - public ScreenshotOptions Screenshots { get; set; } = null; + public IEnumerable Operators { get; set; } = new IOperator[] + { + new UGUIClickOperator(), // Specify screen click point strategy as a constructor argument, if necessary + new UGUIClickAndHoldOperator(), // Specify screen click point strategy and hold millis, if necessary + new UGUITextInputOperator(), // Specify random text input strategy, if necessary + }; } } diff --git a/Runtime/Operators/ClickOperator.cs b/Runtime/Operators/ClickOperator.cs deleted file mode 100644 index a1d79bd..0000000 --- a/Runtime/Operators/ClickOperator.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) 2023 Koji Hasegawa. -// This software is released under the MIT License. - -using System; -using System.Linq; -using TestHelper.Monkey.Annotations; -using TestHelper.Monkey.ScreenPointStrategies; -using UnityEngine; -using UnityEngine.EventSystems; - -namespace TestHelper.Monkey.Operators -{ - internal static class ClickOperator - { - internal static bool CanClick(MonoBehaviour component) - { - if (component.gameObject.TryGetComponent(typeof(IgnoreAnnotation), out _)) - { - return false; - } - - if (component as EventTrigger) - { - return ((EventTrigger)component).triggers.Any(x => x.eventID == EventTriggerType.PointerClick); - } - - return component.GetType().GetInterfaces().Contains(typeof(IPointerClickHandler)); - } - - internal static void Click(MonoBehaviour component, Func screenPointStrategy = null) - { - if (!(component is IPointerClickHandler handler)) - { - return; - } - - screenPointStrategy = screenPointStrategy ?? DefaultScreenPointStrategy.GetScreenPoint; - var eventData = new PointerEventData(EventSystem.current) - { - position = screenPointStrategy(component.gameObject) - }; - handler.OnPointerClick(eventData); - } - } -} diff --git a/Runtime/Operators/IOperator.cs b/Runtime/Operators/IOperator.cs new file mode 100644 index 0000000..b7f2db9 --- /dev/null +++ b/Runtime/Operators/IOperator.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2023-2024 Koji Hasegawa. +// This software is released under the MIT License. + +using System.Threading; +using Cysharp.Threading.Tasks; +using UnityEngine; + +namespace TestHelper.Monkey.Operators +{ + /// + /// Matcher and Operator pair for monkey testing. + /// Implement the IsMatch method to determine whether an operation such as click is possible, and the Operate method to execute the operation. + /// + /// + /// If required parameters for the operation, such as hold time, input text strategy, etc., keep them in instance fields of the implementation class. + /// + public interface IOperator + { + /// + /// Returns operator type. + /// Intended for use in capture and playback features. + /// + OperatorType Type { get; } + + /// + /// Returns if can operate target component this Operator. + /// + /// Target component + /// True if can operate component this Operator. + bool CanOperate(Component component); + + /// + /// Execute this operator in monkey testing. + /// + /// + /// If required parameters for the operation, such as hold time, input text strategy, etc., keep them in instance fields of the implementation class. + /// If you want to add parameters for execution outside of monkey tests, define a sub-interface (e.g., ITextInputOperator). + /// + /// Target component + /// + /// + UniTask OperateAsync(Component component, CancellationToken cancellationToken = default); + } +} diff --git a/Runtime/Operators/IOperator.cs.meta b/Runtime/Operators/IOperator.cs.meta new file mode 100644 index 0000000..392a9e1 --- /dev/null +++ b/Runtime/Operators/IOperator.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: dff8fbb758d241068cde3679f55aa64d +timeCreated: 1712411095 \ No newline at end of file diff --git a/Runtime/Operators/ITextInputOperator.cs b/Runtime/Operators/ITextInputOperator.cs new file mode 100644 index 0000000..4b8b27a --- /dev/null +++ b/Runtime/Operators/ITextInputOperator.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2023-2024 Koji Hasegawa. +// This software is released under the MIT License. + +using System.Threading; +using Cysharp.Threading.Tasks; +using UnityEngine; + +namespace TestHelper.Monkey.Operators +{ + /// + /// Matcher and Operator pair for text input component. + /// Added specify input string method for scenario testing. + /// + public interface ITextInputOperator : IOperator + { + /// + /// Text input with specified text. + /// + /// Target component + /// text to input + /// + /// + UniTask OperateAsync(Component component, string text, CancellationToken cancellationToken = default); + } +} diff --git a/Runtime/Operators/ITextInputOperator.cs.meta b/Runtime/Operators/ITextInputOperator.cs.meta new file mode 100644 index 0000000..0860f5f --- /dev/null +++ b/Runtime/Operators/ITextInputOperator.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 49820168d9fc4264acc7ab3e470d2d60 +timeCreated: 1712491895 \ No newline at end of file diff --git a/Runtime/Operators/OperatorType.cs b/Runtime/Operators/OperatorType.cs new file mode 100644 index 0000000..a48b1c8 --- /dev/null +++ b/Runtime/Operators/OperatorType.cs @@ -0,0 +1,24 @@ +// Copyright (c) 2023-2024 Koji Hasegawa. +// This software is released under the MIT License. + +namespace TestHelper.Monkey.Operators +{ + /// + /// IOperator operation types. + /// Intended for use in capture and playback features. + /// + public enum OperatorType + { + Click, // a.k.a. tap + ClickAndHold, // a.k.a. touch and hold, long press + DoubleClick, // a.k.a. double tap + TextInput, // Recommended to implement ITextInputOperator + DragAndDrop, + Swipe, + Flick, + Pinch, + Hover, // Hover mouse cursor + RightClick, // Click right button on mouse + ScrollWheel, // Scroll wheel on mouse. scrolling up/down and tilting left/right. + } +} diff --git a/Runtime/Operators/OperatorType.cs.meta b/Runtime/Operators/OperatorType.cs.meta new file mode 100644 index 0000000..c345f41 --- /dev/null +++ b/Runtime/Operators/OperatorType.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 11e480d32842484b83f7e788591ad2ea +timeCreated: 1712452936 \ No newline at end of file diff --git a/Runtime/Operators/TextInputOperator.cs b/Runtime/Operators/TextInputOperator.cs deleted file mode 100644 index 72a8fad..0000000 --- a/Runtime/Operators/TextInputOperator.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) 2023 Koji Hasegawa. -// This software is released under the MIT License. - -using System; -using TestHelper.Monkey.Annotations; -using TestHelper.Monkey.Random; -using UnityEngine; -using UnityEngine.UI; - -namespace TestHelper.Monkey.Operators -{ - internal static class TextInputOperator - { - internal static bool CanTextInput(MonoBehaviour component) - { - if (component.gameObject.TryGetComponent(typeof(IgnoreAnnotation), out _)) - { - return false; - } - - return component is InputField; - } - - internal static void Input( - MonoBehaviour component, - Func randomStringParams, - IRandomString randomString - ) - { - if (!(component is InputField inputField)) - { - return; - } - - var annotation = component.gameObject.GetComponent(); - if (annotation != null) - { - // Overwrite rule if annotation is attached. - randomStringParams = _ => new RandomStringParameters( - (int)annotation.minimumLength, - (int)annotation.maximumLength, - annotation.charactersKind); - } - - inputField.text = randomString.Next(randomStringParams(component.gameObject)); - } - } -} diff --git a/Runtime/Operators/TouchAndHoldOperator.cs b/Runtime/Operators/TouchAndHoldOperator.cs deleted file mode 100644 index ae42b90..0000000 --- a/Runtime/Operators/TouchAndHoldOperator.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) 2023 Koji Hasegawa. -// This software is released under the MIT License. - -using System; -using System.Linq; -using System.Threading; -using Cysharp.Threading.Tasks; -using TestHelper.Monkey.Annotations; -using TestHelper.Monkey.ScreenPointStrategies; -using UnityEngine; -using UnityEngine.EventSystems; - -namespace TestHelper.Monkey.Operators -{ - internal static class TouchAndHoldOperator - { - internal static bool CanTouchAndHold(MonoBehaviour component) - { - if (component.gameObject.TryGetComponent(typeof(IgnoreAnnotation), out _)) - { - return false; - } - - if (component as EventTrigger) - { - return ((EventTrigger)component).triggers.Any(x => x.eventID == EventTriggerType.PointerDown) && - ((EventTrigger)component).triggers.Any(x => x.eventID == EventTriggerType.PointerUp); - } - - var interfaces = component.GetType().GetInterfaces(); - return interfaces.Contains(typeof(IPointerDownHandler)) && interfaces.Contains(typeof(IPointerUpHandler)); - } - - internal static async UniTask TouchAndHold( - MonoBehaviour component, - Func screenPointStrategy = null, - int delayMillis = 1000, - CancellationToken cancellationToken = default - ) - { - if (!(component is IPointerDownHandler downHandler) || !(component is IPointerUpHandler upHandler)) - { - return; - } - - screenPointStrategy = screenPointStrategy ?? DefaultScreenPointStrategy.GetScreenPoint; - var eventData = new PointerEventData(EventSystem.current) - { - position = screenPointStrategy(component.gameObject) - }; - - downHandler.OnPointerDown(eventData); - await UniTask.Delay(TimeSpan.FromMilliseconds(delayMillis), cancellationToken: cancellationToken); - - if (component == null) - { - return; - } - - upHandler.OnPointerUp(eventData); - } - } -} diff --git a/Runtime/Operators/UGUIClickAndHoldOperator.cs b/Runtime/Operators/UGUIClickAndHoldOperator.cs new file mode 100644 index 0000000..473c600 --- /dev/null +++ b/Runtime/Operators/UGUIClickAndHoldOperator.cs @@ -0,0 +1,71 @@ +// Copyright (c) 2023-2024 Koji Hasegawa. +// This software is released under the MIT License. + +using System; +using System.Linq; +using System.Threading; +using Cysharp.Threading.Tasks; +using TestHelper.Monkey.ScreenPointStrategies; +using UnityEngine; +using UnityEngine.EventSystems; + +namespace TestHelper.Monkey.Operators +{ + /// + /// Click and hold operator for Unity UI (uGUI) components. + /// a.k.a. touch and hold, long press. + /// + public class UGUIClickAndHoldOperator : IOperator + { + private readonly int _holdMillis; + private readonly Func _getScreenPoint; + private readonly PointerEventData _eventData = new PointerEventData(EventSystem.current); + + /// + /// Constructor. + /// + /// Hold time in milliseconds + /// The function returns the screen click position. Default is DefaultScreenPointStrategy.GetScreenPoint. + public UGUIClickAndHoldOperator(int holdMillis = 1000, Func getScreenPoint = null) + { + this._holdMillis = holdMillis; + this._getScreenPoint = getScreenPoint ?? DefaultScreenPointStrategy.GetScreenPoint; + } + + /// + public OperatorType Type => OperatorType.ClickAndHold; + + /// + public bool CanOperate(Component component) + { + if (component as EventTrigger) + { + return ((EventTrigger)component).triggers.Any(x => x.eventID == EventTriggerType.PointerDown) && + ((EventTrigger)component).triggers.Any(x => x.eventID == EventTriggerType.PointerUp); + } + + var interfaces = component.GetType().GetInterfaces(); + return interfaces.Contains(typeof(IPointerDownHandler)) && interfaces.Contains(typeof(IPointerUpHandler)); + } + + /// + public async UniTask OperateAsync(Component component, CancellationToken cancellationToken = default) + { + if (!(component is IPointerDownHandler downHandler) || !(component is IPointerUpHandler upHandler)) + { + throw new ArgumentException("Component must implement IPointerDownHandler and IPointerUpHandler."); + } + + _eventData.position = _getScreenPoint(component.gameObject); + downHandler.OnPointerDown(_eventData); + await UniTask.Delay(TimeSpan.FromMilliseconds(_holdMillis), cancellationToken: cancellationToken); + + if (component == null || component.gameObject == null) + { + return; + } + + upHandler.OnPointerUp(_eventData); + } + } +} diff --git a/Runtime/Operators/TouchAndHoldOperator.cs.meta b/Runtime/Operators/UGUIClickAndHoldOperator.cs.meta similarity index 100% rename from Runtime/Operators/TouchAndHoldOperator.cs.meta rename to Runtime/Operators/UGUIClickAndHoldOperator.cs.meta diff --git a/Runtime/Operators/UGUIClickOperator.cs b/Runtime/Operators/UGUIClickOperator.cs new file mode 100644 index 0000000..7be8964 --- /dev/null +++ b/Runtime/Operators/UGUIClickOperator.cs @@ -0,0 +1,59 @@ +// Copyright (c) 2023-2024 Koji Hasegawa. +// This software is released under the MIT License. + +using System; +using System.Linq; +using System.Threading; +using Cysharp.Threading.Tasks; +using TestHelper.Monkey.ScreenPointStrategies; +using UnityEngine; +using UnityEngine.EventSystems; + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + +namespace TestHelper.Monkey.Operators +{ + /// + /// Click (tap) operator for Unity UI (uGUI) components. + /// + public class UGUIClickOperator : IOperator + { + private readonly Func _getScreenPoint; + private readonly PointerEventData _eventData = new PointerEventData(EventSystem.current); + + /// + /// Constructor. + /// + /// The function returns the screen click position. Default is DefaultScreenPointStrategy.GetScreenPoint. + public UGUIClickOperator(Func getScreenPoint = null) + { + this._getScreenPoint = getScreenPoint ?? DefaultScreenPointStrategy.GetScreenPoint; + } + + /// + public OperatorType Type => OperatorType.Click; + + /// + public bool CanOperate(Component component) + { + if (component as EventTrigger) + { + return ((EventTrigger)component).triggers.Any(x => x.eventID == EventTriggerType.PointerClick); + } + + return component.GetType().GetInterfaces().Contains(typeof(IPointerClickHandler)); + } + + /// + public async UniTask OperateAsync(Component component, CancellationToken cancellationToken = default) + { + if (!(component is IPointerClickHandler handler)) + { + throw new ArgumentException("Component must implement IPointerClickHandler."); + } + + _eventData.position = _getScreenPoint(component.gameObject); + handler.OnPointerClick(_eventData); + } + } +} diff --git a/Runtime/Operators/ClickOperator.cs.meta b/Runtime/Operators/UGUIClickOperator.cs.meta similarity index 100% rename from Runtime/Operators/ClickOperator.cs.meta rename to Runtime/Operators/UGUIClickOperator.cs.meta diff --git a/Runtime/Operators/UGUITextInputOperator.cs b/Runtime/Operators/UGUITextInputOperator.cs new file mode 100644 index 0000000..2aeb79b --- /dev/null +++ b/Runtime/Operators/UGUITextInputOperator.cs @@ -0,0 +1,85 @@ +// Copyright (c) 2023-2024 Koji Hasegawa. +// This software is released under the MIT License. + +using System; +using System.Threading; +using Cysharp.Threading.Tasks; +using TestHelper.Monkey.Annotations; +using TestHelper.Monkey.Random; +using TestHelper.Random; +using UnityEngine; +using UnityEngine.UI; + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + +namespace TestHelper.Monkey.Operators +{ + /// + /// Text input operator for Unity UI (uGUI) InputField component. + /// + public class UGUITextInputOperator : ITextInputOperator + { + private readonly Func _randomStringParams; + private readonly IRandomString _randomString; + + /// + /// Input random text that is randomly generated by + /// + /// Random string generation parameters + /// Random string generator + public UGUITextInputOperator( + Func randomStringParams = null, + IRandomString randomString = null) + { + _randomStringParams = randomStringParams ?? (_ => RandomStringParameters.Default); + _randomString = randomString ?? new RandomStringImpl(new RandomWrapper()); + } + + /// + public OperatorType Type => OperatorType.TextInput; + + /// + public bool CanOperate(Component component) + { + return component is InputField; + } + + /// + public async UniTask OperateAsync(Component component, CancellationToken cancellationToken = default) + { + if (!(component is InputField inputField)) + { + throw new ArgumentException("Component must be InputField class."); + } + + Func randomStringParams; + var annotation = component.gameObject.GetComponent(); + if (annotation != null) + { + // Overwrite rule if annotation is attached. + randomStringParams = _ => new RandomStringParameters( + (int)annotation.minimumLength, + (int)annotation.maximumLength, + annotation.charactersKind); + } + else + { + randomStringParams = _randomStringParams; + } + + inputField.text = _randomString.Next(randomStringParams(component.gameObject)); + } + + /// + public async UniTask OperateAsync(Component component, string text, + CancellationToken cancellationToken = default) + { + if (!(component is InputField inputField)) + { + throw new ArgumentException("Component must be InputField class."); + } + + inputField.text = text; + } + } +} diff --git a/Runtime/Operators/TextInputOperator.cs.meta b/Runtime/Operators/UGUITextInputOperator.cs.meta similarity index 100% rename from Runtime/Operators/TextInputOperator.cs.meta rename to Runtime/Operators/UGUITextInputOperator.cs.meta diff --git a/Samples~/uGUI Demo/Scripts/Runtime/MonkeyTestButton.cs b/Samples~/uGUI Demo/Scripts/Runtime/MonkeyTestButton.cs index 444889a..3dbb5db 100644 --- a/Samples~/uGUI Demo/Scripts/Runtime/MonkeyTestButton.cs +++ b/Samples~/uGUI Demo/Scripts/Runtime/MonkeyTestButton.cs @@ -4,6 +4,7 @@ using System; using System.Threading; using Cysharp.Threading.Tasks; +using TestHelper.Monkey.Operators; using TestHelper.Monkey.ScreenshotFilenameStrategies; using UnityEngine; using UnityEngine.UI; @@ -19,9 +20,9 @@ public class MonkeyTestButton : MonoBehaviour public int lifetimeSeconds = 30; public int delayMillis = 200; public int secondsToErrorForNoInteractiveComponent = 5; - public int touchAndHoldDelayMillis = 1000; public bool gizmos; public bool screenshots = true; + public int touchAndHoldDelayMillis = 1000; private Text _buttonLabel; @@ -53,11 +54,16 @@ private async UniTask OnClick() Lifetime = TimeSpan.FromSeconds(lifetimeSeconds), DelayMillis = delayMillis, SecondsToErrorForNoInteractiveComponent = secondsToErrorForNoInteractiveComponent, - TouchAndHoldDelayMillis = touchAndHoldDelayMillis, Gizmos = gizmos, Screenshots = screenshots ? new ScreenshotOptions() { FilenameStrategy = new CounterBasedStrategy("uGUI Demo") } - : null + : null, + Operators = new IOperator[] + { + new UGUIClickOperator(), + new UGUIClickAndHoldOperator(holdMillis: touchAndHoldDelayMillis), + new UGUITextInputOperator(), + }, }; _cts = new CancellationTokenSource(); diff --git a/Samples~/uGUI Demo/Tests/Runtime/ScenarioTest.cs b/Samples~/uGUI Demo/Tests/Runtime/ScenarioTest.cs index 5aed5ef..534eb45 100644 --- a/Samples~/uGUI Demo/Tests/Runtime/ScenarioTest.cs +++ b/Samples~/uGUI Demo/Tests/Runtime/ScenarioTest.cs @@ -1,9 +1,12 @@ // Copyright (c) 2023-2024 Koji Hasegawa. // This software is released under the MIT License. +using System.Linq; using System.Threading.Tasks; using NUnit.Framework; using TestHelper.Attributes; +using TestHelper.Monkey.Operators; +using UnityEngine; using UnityEngine.UI; namespace TestHelper.Monkey.Samples.UGUIDemo @@ -12,6 +15,7 @@ namespace TestHelper.Monkey.Samples.UGUIDemo public class ScenarioTest { private readonly GameObjectFinder _finder = new GameObjectFinder(3.0d); + private readonly MonkeyConfig _config = new MonkeyConfig(); [TestCase("Profile")] [TestCase("Difficulty")] @@ -26,24 +30,29 @@ public async Task OpenSubScreens(string target) // When click Start button, then open Home screen. var startButton = await _finder.FindByNameAsync("StartButton", interactable: true); - var startComponent = InteractiveComponent.CreateInteractableComponent(startButton.GetComponent