diff --git a/README.md b/README.md
index 730d41f..f525fd3 100644
--- a/README.md
+++ b/README.md
@@ -161,5 +161,22 @@ and define the following in your `csproj`:
```
These attributes will ask for the runtime test engine to replace the ones defined by the `Uno.UI.RuntimeTests.Engine` package.
+## Test runner (UnitTestsControl) filtering syntax
+- Search terms are separated by space. Multiple consecutive spaces are treated same as one.
+- Multiple search terms are chained with AND logic.
+- Search terms are case insensitive.
+- `-` can be used before any term for exclusion, effectively inverting the results.
+- Special tags can be used to match certain part of the test: // syntax: tag:term
+ - `class` or `c` matches the class name
+ - `method` or `m` matches the method name
+ - `displayname` or `d` matches the display name in [DataRow]
+- Search term without a prefixing tag will match either of method name or class name.
+
+Examples:
+- `listview`
+- `listview measure`
+- `listview measure -recycle`
+- `c:listview m:measure -m:recycle`
+
## Running the tests automatically during CI
_TBD_
diff --git a/src/TestApp/shared/EngineFeatureTests.cs b/src/TestApp/shared/EngineFeatureTests.cs
new file mode 100644
index 0000000..fc1da93
--- /dev/null
+++ b/src/TestApp/shared/EngineFeatureTests.cs
@@ -0,0 +1,86 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+#if HAS_UNO_WINUI || WINDOWS_WINUI
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+#else
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+#endif
+
+namespace Uno.UI.RuntimeTests.Engine
+{
+ ///
+ /// Contains tests relevant to the RTT engine features.
+ ///
+ [TestClass]
+ public class MetaTests
+ {
+ [TestMethod]
+ [RunsOnUIThread]
+ public async Task When_Test_ContentHelper()
+ {
+ var SUT = new TextBlock() { Text = "Hello" };
+ UnitTestsUIContentHelper.Content = SUT;
+
+ await UnitTestsUIContentHelper.WaitForIdle();
+ await UnitTestsUIContentHelper.WaitForLoaded(SUT);
+ }
+
+ [TestMethod]
+ [DataRow("hello", DisplayName = "hello test")]
+ [DataRow("goodbye", DisplayName = "goodbye test")]
+ public void When_DisplayName(string text)
+ {
+ }
+
+ [TestMethod]
+ [DataRow("at index 0")]
+ [DataRow("at index 1")]
+ [DataRow("at index 2")]
+ public void When_DataRows(string arg)
+ {
+ }
+
+ [TestMethod]
+ [DataRow("at index 0", "asd")]
+ [DataRow("at index 1", "asd")]
+ [DataRow("at index 2", "zxc")]
+ public void When_DataRows2(string arg, string arg2)
+ {
+ }
+
+ [DataTestMethod]
+ [DynamicData(nameof(GetDynamicData), DynamicDataSourceType.Method)]
+ public void When_DynamicData(string arg, string arg2)
+ {
+ }
+
+ [TestMethod]
+ [InjectedPointer(Windows.Devices.Input.PointerDeviceType.Touch)]
+ [InjectedPointer(Windows.Devices.Input.PointerDeviceType.Pen)]
+ public void When_InjectedPointers()
+ {
+ }
+
+ [TestMethod]
+ [DataRow("at index 0")]
+ [DataRow("at index 1")]
+ [InjectedPointer(Windows.Devices.Input.PointerDeviceType.Touch)]
+ [InjectedPointer(Windows.Devices.Input.PointerDeviceType.Pen)]
+ public void When_InjectedPointers_DataRows(string arg)
+ {
+ }
+
+ public static IEnumerable GetDynamicData()
+ {
+ yield return new object[] { "at index 0", "asd" };
+ yield return new object[] { "at index 1", "asd" };
+ yield return new object[] { "at index 2", "zxc" };
+ }
+ }
+}
diff --git a/src/TestApp/shared/Extensions/AssertExtensions.cs b/src/TestApp/shared/Extensions/AssertExtensions.cs
new file mode 100644
index 0000000..93e707a
--- /dev/null
+++ b/src/TestApp/shared/Extensions/AssertExtensions.cs
@@ -0,0 +1,21 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Uno.UI.RuntimeTests.Engine.Extensions;
+
+internal static class AssertExtensions
+{
+ public static void AreEqual(this Assert assert, T expected, T actual, IEqualityComparer comparer)
+ {
+ if (!comparer.Equals(expected, actual))
+ {
+ Assert.Fail(string.Join("\n",
+ "AreEqual failed.",
+ $"Expected: {expected}",
+ $"Actual: {actual}"
+ ));
+ }
+ }
+}
diff --git a/src/TestApp/shared/SanityTests.cs b/src/TestApp/shared/SanityTests.cs
index c2b0039..4a00eca 100644
--- a/src/TestApp/shared/SanityTests.cs
+++ b/src/TestApp/shared/SanityTests.cs
@@ -14,6 +14,9 @@
namespace Uno.UI.RuntimeTests.Engine
{
+ ///
+ /// Contains sanity/smoke tests used to assert basic scenarios.
+ ///
[TestClass]
public class SanityTests
{
@@ -28,24 +31,6 @@ public async Task Is_Still_Sane()
await Task.Delay(2000);
}
- [TestMethod]
- [RunsOnUIThread]
- public async Task When_Test_ContentHelper()
- {
- var SUT = new TextBlock() { Text = "Hello" };
- UnitTestsUIContentHelper.Content = SUT;
-
- await UnitTestsUIContentHelper.WaitForIdle();
- await UnitTestsUIContentHelper.WaitForLoaded(SUT);
- }
-
- [TestMethod]
- [DataRow("hello", DisplayName = "hello test")]
- [DataRow("goodbye", DisplayName = "goodbye test")]
- public void Is_Sane_With_Cases(string text)
- {
- }
-
#if DEBUG
[TestMethod]
public async Task No_Longer_Sane() // expected to fail
diff --git a/src/TestApp/shared/SearchQueryTests.cs b/src/TestApp/shared/SearchQueryTests.cs
new file mode 100644
index 0000000..8bf050b
--- /dev/null
+++ b/src/TestApp/shared/SearchQueryTests.cs
@@ -0,0 +1,123 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Uno.UI.RuntimeTests.Engine.Extensions;
+using static Uno.UI.RuntimeTests.UnitTestsControl;
+
+namespace Uno.UI.RuntimeTests.Engine;
+
+[TestClass]
+public partial class SearchQueryTests
+{
+ [TestMethod]
+ [DataRow("asd")] // simple
+ [DataRow("^asd")] // match start
+ [DataRow("asd$")] // match end
+ [DataRow("^asd$")] // full match
+ public void When_SearchPredicatePart_Parse(string input)
+ {
+ var actual = SearchPredicatePart.Parse(input);
+ var expected = input switch
+ {
+ "asd" => new SearchPredicatePart(input, "asd"),
+ "^asd" => new SearchPredicatePart(input, "asd", MatchStart: true),
+ "asd$" => new SearchPredicatePart(input, "asd", MatchEnd: true),
+ "^asd$" => new SearchPredicatePart(input, "asd", MatchStart: true, MatchEnd: true),
+
+ _ => throw new ArgumentOutOfRangeException(input),
+ };
+
+ Assert.AreEqual(expected, actual);
+ }
+
+ [TestMethod]
+ [DataRow("asd")] // simple
+ [DataRow("^asd,qwe$,^zxc$")] // multi parts
+ [DataRow("-asd")] // exclusion
+ [DataRow("tag:asd")] // tagged
+ public void When_SearchPredicate_ParseFragment(string input)
+ {
+ var actual = SearchPredicate.ParseFragment(input);
+ var expected = input switch
+ {
+ "asd" => new SearchPredicate("asd", "asd", Parts: new SearchPredicatePart("asd", "asd")),
+ "^asd,qwe$,^zxc$" => new SearchPredicate("^asd,qwe$,^zxc$", "^asd,qwe$,^zxc$", Parts: new[]{
+ new SearchPredicatePart("^asd", "asd", MatchStart: true),
+ new SearchPredicatePart("qwe$", "qwe", MatchEnd: true),
+ new SearchPredicatePart("^zxc$", "zxc", MatchStart: true, MatchEnd: true),
+ }),
+ "-asd" => new SearchPredicate("-asd", "asd", Exclusion: true, Parts: new[]{
+ new SearchPredicatePart("asd", "asd"),
+ }),
+ "tag:asd" => new SearchPredicate("tag:asd", "asd", Tag: "tag", Parts: new[]{
+ new SearchPredicatePart("asd", "asd"),
+ }),
+
+ _ => throw new ArgumentOutOfRangeException(input),
+ };
+
+ Assert.That.AreEqual(expected, actual, SearchPredicate.DefaultComparer);
+ }
+
+ [TestMethod]
+ [DataRow("asd")] // simple
+ [DataRow("asd qwe")] // multi fragments
+ [DataRow("class:asd method:qwe display_name:zxc -123")] // tags
+ [DataRow("c:asd m:qwe d:zxc -123")] // aliased tags
+ [DataRow("d:\"^asd \\\", asd$\"")] // quoted with escape
+ [DataRow("at:asd\"asd\"asd,^asd,asd$")] // inner literal quote
+ [DataRow("asd @0")] // custom prefix
+ [DataRow("p:\"index 0\",\"index 1\" -p:\"asd\"")] // multiple quotes
+
+ public void When_SearchPredicate_Parse(string input)
+ {
+ var actual = SearchPredicate.ParseQuery(input);
+ var predicates = input switch
+ {
+ "asd" => new SearchPredicate[] { new("asd", "asd", Parts: new SearchPredicatePart[] { new("asd", "asd"), }), },
+ "asd qwe" => new SearchPredicate[] {
+ new("asd", "asd", Parts: new SearchPredicatePart[] { new("asd", "asd"), }),
+ new("qwe", "qwe", Parts: new SearchPredicatePart[] { new("qwe", "qwe"), }),
+ },
+ "class:asd method:qwe display_name:zxc -123" => new SearchPredicate[] {
+ new("class:asd", "asd", Tag: "class", Parts: new SearchPredicatePart[] { new("asd", "asd"), }),
+ new("method:qwe", "qwe", Tag: "method", Parts: new SearchPredicatePart[] { new("qwe", "qwe"), }),
+ new("display_name:zxc", "zxc", Tag: "display_name", Parts: new SearchPredicatePart[] { new("zxc", "zxc"), }),
+ new("-123", "123", Exclusion: true, Parts: new SearchPredicatePart[] { new("123", "123"), }),
+ },
+ "c:asd m:qwe d:zxc -123" => new SearchPredicate[] {
+ new("c:asd", "asd", Tag: "class", Parts: new SearchPredicatePart[] { new("asd", "asd"), }),
+ new("m:qwe", "qwe", Tag: "method", Parts: new SearchPredicatePart[] { new("qwe", "qwe"), }),
+ new("d:zxc", "zxc", Tag: "display_name", Parts: new SearchPredicatePart[] { new("zxc", "zxc"), }),
+ new("-123", "123", Exclusion: true, Parts: new SearchPredicatePart[] { new("123", "123"), }),
+ },
+ "d:\"^asd \\\", asd$\"" => new SearchPredicate[] {
+ new("d:\"^asd \\\", asd$\"", "\"^asd \\\", asd$\"", Tag: "display_name", Parts: new SearchPredicatePart[] {
+ new("\"^asd \\\", asd$\"", "asd \", asd", MatchStart: true, MatchEnd: true),
+ }),
+ },
+ "at:asd\"asd\"asd,^asd,asd$" => new SearchPredicate[] {
+ new("at:asd\"asd\"asd,^asd,asd$", "asd\"asd\"asd,^asd,asd$", Tag: "at", Parts: new SearchPredicatePart[] {
+ new("asd\"asd\"asd", "asd\"asd\"asd"),
+ new ("^asd", "asd", MatchStart: true),
+ new ("asd$", "asd", MatchEnd: true),
+ }),
+ },
+ "asd @0" => new SearchPredicate[] {
+ new("asd", "asd", Parts: new SearchPredicatePart[] { new ("asd", "asd"), }),
+ new("@0", "0", Tag: "at", Parts: new SearchPredicatePart[] { new ("0", "0"), }),
+ },
+ "p:\"index 0\",\"index 1\" -p:\"asd\"" => new SearchPredicate[] {
+ new("p:\"index 0\",\"index 1\"", "\"index 0\",\"index 1\"", Tag: "params", Parts: new SearchPredicatePart[] { new("\"index 0\"", "index 0"), new("\"index 1\"", "index 1") }),
+ new("-p:\"asd\"", "\"asd\"", Exclusion: true, Tag: "params", Parts: new SearchPredicatePart[] { new("\"asd\"", "asd") })
+ },
+
+ _ => throw new ArgumentOutOfRangeException(input),
+ };
+ var expected = new SearchPredicateCollection(predicates!);
+
+ Assert.That.AreEqual(expected, actual, SearchPredicate.DefaultQueryComparer);
+ }
+}
diff --git a/src/TestApp/shared/Uno.UI.RuntimeTests.Engine.Shared.shproj b/src/TestApp/shared/Uno.UI.RuntimeTests.Engine.Shared.shproj
index bab8c8a..f51fbd9 100644
--- a/src/TestApp/shared/Uno.UI.RuntimeTests.Engine.Shared.shproj
+++ b/src/TestApp/shared/Uno.UI.RuntimeTests.Engine.Shared.shproj
@@ -11,6 +11,8 @@
+ <_Globbed_Compile Remove="Extensions\AssertExtensions.cs" />
<_Globbed_Compile Remove="MetaAttributes.cs" />
+ <_Globbed_Compile Remove="SearchQueryTests.cs" />
\ No newline at end of file
diff --git a/src/TestApp/shared/uno.ui.runtimetests.engine.Shared.projitems b/src/TestApp/shared/uno.ui.runtimetests.engine.Shared.projitems
index 0083c1d..28d2fa4 100644
--- a/src/TestApp/shared/uno.ui.runtimetests.engine.Shared.projitems
+++ b/src/TestApp/shared/uno.ui.runtimetests.engine.Shared.projitems
@@ -18,10 +18,13 @@
App.xaml
+
MainPage.xaml
+
+
diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Extensions/StringExtensions.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Extensions/StringExtensions.cs
new file mode 100644
index 0000000..cc686fa
--- /dev/null
+++ b/src/Uno.UI.RuntimeTests.Engine.Library/Extensions/StringExtensions.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace Uno.UI.RuntimeTests.Extensions;
+
+internal static partial class StringExtensions
+{
+ ///
+ /// Like , but allows exception to be made with a Regex pattern.
+ ///
+ ///
+ ///
+ /// segments matched by the regex will not be splited.
+ ///
+ ///
+ public static string[] SplitWithIgnore(this string input, char separator, string ignoredPattern, bool skipEmptyEntries)
+ {
+ var ignores = Regex.Matches(input, ignoredPattern);
+
+ var shards = new List();
+ for (int i = 0; i < input.Length; i++)
+ {
+ var nextSpaceDelimiter = input.IndexOf(separator, i);
+
+ // find the next space, if inside a quote
+ while (nextSpaceDelimiter != -1 && ignores.FirstOrDefault(x => InRange(x, nextSpaceDelimiter)) is { } enclosingIgnore)
+ {
+ nextSpaceDelimiter = enclosingIgnore.Index + enclosingIgnore.Length is { } afterIgnore && afterIgnore < input.Length
+ ? input.IndexOf(separator, afterIgnore)
+ : -1;
+ }
+
+ if (nextSpaceDelimiter != -1)
+ {
+ shards.Add(input.Substring(i, nextSpaceDelimiter - i));
+ i = nextSpaceDelimiter;
+
+ // skip multiple continuous spaces
+ while (skipEmptyEntries && i + 1 < input.Length && input[i + 1] == separator) i++;
+ }
+ else
+ {
+ shards.Add(input.Substring(i));
+ break;
+ }
+ }
+
+ return shards.ToArray();
+
+ bool InRange(Match x, int index) => x.Index <= index && index < (x.Index + x.Length);
+ }
+}
diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestClassInfo.cs b/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestClassInfo.cs
index d32c298..e85808d 100644
--- a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestClassInfo.cs
+++ b/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestClassInfo.cs
@@ -11,29 +11,26 @@ namespace Uno.UI.RuntimeTests;
public class UnitTestClassInfo
{
public UnitTestClassInfo(
- Type? type,
- MethodInfo[]? tests,
+ Type type,
+ MethodInfo[] tests,
MethodInfo? initialize,
MethodInfo? cleanup)
{
Type = type;
- TestClassName = Type?.Name ?? "(null)";
Tests = tests;
Initialize = initialize;
Cleanup = cleanup;
}
- public string TestClassName { get; }
+ public Type Type { get; }
- public Type? Type { get; }
-
- public MethodInfo[]? Tests { get; }
+ public MethodInfo[] Tests { get; }
public MethodInfo? Initialize { get; }
public MethodInfo? Cleanup { get; }
- public override string ToString() => TestClassName;
+ public override string ToString() => Type.Name;
}
#endif
\ No newline at end of file
diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestEngineConfig.cs b/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestEngineConfig.cs
index c66d02f..a19f6f3 100644
--- a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestEngineConfig.cs
+++ b/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestEngineConfig.cs
@@ -10,7 +10,7 @@ public class UnitTestEngineConfig
public static UnitTestEngineConfig Default { get; } = new UnitTestEngineConfig();
- public string[]? Filters { get; set; }
+ public string? Query { get; set; }
public int Attempts { get; set; } = DefaultRepeatCount;
diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestMethodInfo.cs b/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestMethodInfo.cs
index 1207771..44e4a1b 100644
--- a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestMethodInfo.cs
+++ b/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestMethodInfo.cs
@@ -13,9 +13,6 @@ namespace Uno.UI.RuntimeTests;
internal record UnitTestMethodInfo
{
- private readonly List _casesParameters;
- private readonly IList _injectedPointerTypes;
-
public UnitTestMethodInfo(object testClassInstance, MethodInfo method)
{
Method = method;
@@ -30,13 +27,13 @@ public UnitTestMethodInfo(object testClassInstance, MethodInfo method)
.SingleOrDefault()
?.ExceptionType;
- _casesParameters = method
+ DataSources = method
.GetCustomAttributes()
.Where(x => x is ITestDataSource)
.Cast()
.ToList();
- _injectedPointerTypes = method
+ InjectedPointerTypes = method
.GetCustomAttributes()
.Select(attr => attr.Type)
.Distinct()
@@ -53,6 +50,10 @@ public UnitTestMethodInfo(object testClassInstance, MethodInfo method)
public bool RunsOnUIThread { get; }
+ public IReadOnlyList DataSources { get; }
+
+ public IReadOnlyList InjectedPointerTypes { get; }
+
private static bool HasCustomAttribute(MemberInfo? testMethod)
=> testMethod?.GetCustomAttribute(typeof(T)) != null;
@@ -73,35 +74,5 @@ public bool IsIgnored(out string ignoreMessage)
ignoreMessage = "";
return false;
}
-
- public IEnumerable GetCases()
- {
- List cases = Enumerable.Empty().ToList();
-
- if (_casesParameters is { Count: 0 })
- {
- cases.Add(new TestCase());
- }
-
- foreach (var testCaseSource in _casesParameters)
- {
- foreach (var caseData in testCaseSource.GetData(Method))
- {
- var data = testCaseSource.GetData(Method)
- .SelectMany(x => x)
- .ToArray();
-
- cases.Add(new TestCase { Parameters = data, DisplayName = testCaseSource.GetDisplayName(Method, data) });
- }
- }
-
- if (_injectedPointerTypes.Any())
- {
- var currentCases = cases;
- cases = _injectedPointerTypes.SelectMany(pointer => currentCases.Select(testCase => testCase with { Pointer = pointer })).ToList();
- }
-
- return cases;
- }
}
#endif
\ No newline at end of file
diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsControl.Filtering.cs b/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsControl.Filtering.cs
new file mode 100644
index 0000000..872729a
--- /dev/null
+++ b/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsControl.Filtering.cs
@@ -0,0 +1,276 @@
+#if !UNO_RUNTIMETESTS_DISABLE_UI
+
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Data;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Uno.UI.RuntimeTests.Extensions;
+using Windows.Devices.Input;
+using static System.StringComparison;
+using KnownTags = Uno.UI.RuntimeTests.UnitTestsControl.SearchPredicate.KnownTags;
+
+namespace Uno.UI.RuntimeTests;
+
+partial class UnitTestsControl
+{
+ private IEnumerable GetTestClasses(SearchPredicateCollection? filters)
+ {
+ var testAssemblies = AppDomain.CurrentDomain.GetAssemblies()
+ .Where(x => x.GetName()?.Name?.EndsWith("Tests", OrdinalIgnoreCase) ?? false)
+ .Concat(new[] { GetType().GetTypeInfo().Assembly })
+ .Distinct();
+ var types = testAssemblies
+ .SelectMany(x => x.GetTypes())
+ .Where(x => filters?.MatchAll(KnownTags.ClassName, x.Name) ?? true);
+
+ if (_ciTestGroupCache != -1)
+ {
+ Console.WriteLine($"Filtering with group #{_ciTestGroupCache} (Groups {_ciTestsGroupCountCache})");
+ }
+
+ return
+ from type in types
+ where type.GetTypeInfo().GetCustomAttribute(typeof(TestClassAttribute)) != null
+ where _ciTestsGroupCountCache == -1 || (_ciTestsGroupCountCache != -1 && (UnitTestsControl.GetTypeTestGroup(type) % _ciTestsGroupCountCache) == _ciTestGroupCache)
+ orderby type.Name
+ let info = BuildType(type)
+ where info is { }
+ select info;
+ }
+ private static IEnumerable GetTestMethods(UnitTestClassInfo @class, SearchPredicateCollection? filters)
+ {
+ var tests = @class.Tests
+ .Select(x => new
+ {
+ Type = @class.Type.Name,
+ x.Name,
+ mc = filters?.MatchAll(KnownTags.Empty, @class.Type.Name),
+ mm = filters?.MatchAll(KnownTags.Empty, x.Name),
+ })
+ .ToArray();
+ return @class.Tests
+ .Where(x => filters is { }
+ ? (filters.MatchAll(KnownTags.Empty, @class.Type.Name) || filters.MatchAll(KnownTags.Empty, x.Name))
+ : true)
+ .Where(x => filters?.MatchAll(KnownTags.MethodName, x.Name) ?? true)
+ .Where(x => filters?.MatchAll(KnownTags.FullName, $"{@class.Type.FullName}.{x.Name}") ?? true);
+ }
+ private static IEnumerable GetTestCases(UnitTestMethodInfo method, SearchPredicateCollection? filters)
+ {
+ return
+ (
+ from source in method.DataSources // get [DataRow, DynamicData] attributes
+ from data in source.GetData(method.Method) // flatten [DataRow, DynamicData] (mostly the latter)
+ from injectedPointerType in method.InjectedPointerTypes.Cast().DefaultIfEmpty()
+ select new TestCase
+ {
+ Parameters = data,
+ DisplayName = source.GetDisplayName(method.Method, data)
+ }
+ )
+ // convert empty (without [DataRow, DynamicData]) to a single null case, so we dont lose them
+ .DefaultIfEmpty()//.Select(x => x ?? new TestCase())
+ .Select((x, i) => new
+ {
+ Case = x,
+ Index = i,
+ IsDataSourced = x is not null,
+ Parameters = x?.Parameters.Any() == true ? string.Join(",", x.Parameters) : null,
+ })
+ .Where(x => filters?.MatchAllOnlyIfValuePresent(KnownTags.Parameters, x.Parameters) ?? true)
+ .Where(x => filters?.MatchAllOnlyIf(KnownTags.AtIndex, x.IsDataSourced, y => MatchIndex(y, x.Index)) ?? true)
+ // fixme: DisplayName will also matches parameters
+ // ^ because ITestDataSource.GetDisplayName returns args if DisplayName is not specified
+ .Where(x => filters?.MatchAllOnlyIfValuePresent(KnownTags.DisplayName, x.Case?.DisplayName) ?? true)
+ .Select(x => x.Case ?? new());
+
+ bool MatchIndex(SearchPredicate predicate, int index)
+ {
+ return predicate.Parts.Any(x => x.Raw == index.ToString());
+ }
+ }
+
+ public record SearchPredicate(string Raw, string Text, bool Exclusion = false, string? Tag = null, params SearchPredicatePart[] Parts)
+ {
+ public static class KnownTags
+ {
+ public const string? Empty = null;
+ public const string ClassName = "class";
+ public const string MethodName = "method";
+ public const string FullName = "full_name";
+ public const string DisplayName = "display_name";
+ public const string AtIndex = "at";
+ public const string Parameters = "params";
+ }
+
+ public static SearchPredicateComparer DefaultComparer = new();
+ public static SearchQueryComparer DefaultQueryComparer = new();
+
+ private static readonly IReadOnlyDictionary NamespaceAliases = // aliases for tag
+ new Dictionary(StringComparer.InvariantCultureIgnoreCase)
+ {
+ ["c"] = KnownTags.ClassName,
+ ["m"] = KnownTags.MethodName,
+ ["f"] = KnownTags.FullName,
+ ["d"] = KnownTags.DisplayName,
+ ["p"] = KnownTags.Parameters,
+ };
+ private static readonly IReadOnlyDictionary SpecialPrefixAliases = // prefixes are tags that don't need ':'
+ new Dictionary(StringComparer.InvariantCultureIgnoreCase)
+ {
+ ["@"] = KnownTags.AtIndex,
+ };
+
+ public static SearchPredicateCollection? ParseQuery(string? query)
+ {
+ if (string.IsNullOrWhiteSpace(query)) return null;
+
+ return new SearchPredicateCollection(query
+ !.SplitWithIgnore(' ', @""".*?(?() // trim null
+ .Where(x => x.Text.Length > 0) // ignore empty tag query eg: "c:"
+ .ToArray()
+ );
+ }
+
+ public static SearchPredicate? ParseFragment(string criteria)
+ {
+ if (string.IsNullOrWhiteSpace(criteria)) return null;
+
+ var raw = criteria.Trim();
+ var text = raw;
+ if (text.StartsWith('-') is var exclusion && exclusion)
+ {
+ text = text.Substring(1);
+ }
+ var tag = default(string?);
+ if (SpecialPrefixAliases.FirstOrDefault(x => text.StartsWith(x.Key)) is { Key.Length: > 0 } prefix)
+ {
+ // process prefixes (tags that dont need ':'): '@0' -> 'at:0'
+ tag = prefix.Value;
+ text = text.Substring(prefix.Key.Length);
+ }
+ else if (text.Split(':', 2) is { Length: 2 } tagParts)
+ {
+ // process tag aliases
+ tag = NamespaceAliases.TryGetValue(tagParts[0], out var value) ? value : tagParts[0];
+ text = tagParts[1];
+ }
+ var parts = text.SplitWithIgnore(',', @""".*?(?
+ Exclusion ^ // use xor to flip the result based on Exclusion
+ Parts.Any(x => (x.MatchStart, x.MatchEnd) switch
+ {
+ (true, false) => input.StartsWith(x.Text, InvariantCultureIgnoreCase),
+ (false, true) => input.EndsWith(x.Text, InvariantCultureIgnoreCase),
+
+ _ => input.Contains(x.Text, InvariantCultureIgnoreCase),
+ });
+
+ public class SearchPredicateComparer : IEqualityComparer
+ {
+ public int GetHashCode(SearchPredicate? obj) => obj?.GetHashCode() ?? -1;
+ public bool Equals(SearchPredicate? x, SearchPredicate? y)
+ {
+ return (x, y) switch
+ {
+ (null, null) => true,
+ (null, _) => false,
+ (_, null) => false,
+ _ =>
+ x.Raw == y.Raw &&
+ x.Text == y.Text &&
+ x.Exclusion == y.Exclusion &&
+ x.Tag == y.Tag &&
+ x.Parts.SequenceEqual(y.Parts),
+ };
+ }
+ }
+
+ public class SearchQueryComparer : IEqualityComparer
+ {
+ public int GetHashCode(SearchPredicateCollection? obj) => obj?.GetHashCode() ?? -1;
+ public bool Equals(SearchPredicateCollection? x, SearchPredicateCollection? y)
+ {
+ return (x, y) switch
+ {
+ (null, null) => true,
+ (null, _) => false,
+ (_, null) => false,
+ _ => x.SequenceEqual(y, DefaultComparer),
+ };
+ }
+ }
+ }
+ public record SearchPredicatePart(string Raw, string Text, bool MatchStart = false, bool MatchEnd = false)
+ {
+ public static SearchPredicatePart Parse(string part)
+ {
+ var raw = part;
+ var text = raw;
+
+ if (text.Length >= 2 && text.StartsWith('"') && text.EndsWith('"'))
+ {
+ // within quoted string, unquote and unescape \" to "
+ text = text
+ .Substring(1, text.Length - 2)
+ .Replace("\\\"", "\"");
+ }
+ if (text.StartsWith("^") is { } matchStart && matchStart)
+ {
+ text = text.Substring(1);
+ }
+ if (text.EndsWith("$") is { } matchEnd && matchEnd)
+ {
+ text = text.Substring(0, text.Length - 1);
+ }
+
+ return new(raw, text, matchStart, matchEnd);
+ }
+ }
+ public class SearchPredicateCollection : ReadOnlyCollection
+ {
+ public SearchPredicateCollection(IList list) : base(list) { }
+
+ ///
+ /// Match value against all filters of specified tag.
+ ///
+ ///
+ ///
+ /// If all 'tag' filters matches the value, or true if the none filters are of 'tag'.
+ public bool MatchAll(string? tag, string value) => this
+ .Where(x => x.Tag == tag)
+ .All(x => x.IsMatch(value));
+
+ public bool MatchAllOnlyIfValuePresent(string? tag, string? value) =>
+ MatchAllOnlyIf(tag, value is not null, x => x.IsMatch(value!));
+
+ public bool MatchAllOnlyIf(string? tag, bool condition, Func match)
+ {
+ var tagFilters = this.Where(x => x.Tag == tag).ToArray();
+
+ return (condition, tagFilters.Any()) switch
+ {
+ (true, true) => tagFilters.All(x => match(x)),
+ (false, true) => false,
+ (_, false) => true,
+ };
+ }
+ }
+}
+
+#endif
\ No newline at end of file
diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsControl.cs b/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsControl.cs
index 4a8c27b..659d258 100644
--- a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsControl.cs
+++ b/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsControl.cs
@@ -65,7 +65,6 @@ public sealed partial class UnitTestsControl : UserControl
#endif
#pragma warning restore CS0109
- private const StringComparison StrComp = StringComparison.InvariantCultureIgnoreCase;
private Task? _runner;
private CancellationTokenSource? _cts = new CancellationTokenSource();
#if DEBUG
@@ -499,7 +498,7 @@ private void EnableConfigPersistence()
consoleOutput.IsChecked = config.IsConsoleOutputEnabled;
runIgnored.IsChecked = config.IsRunningIgnored;
retry.IsChecked = config.Attempts > 1;
- testFilter.Text = string.Join(";", config.Filters ?? Array.Empty());
+ testFilter.Text = config.Query ?? string.Empty;
}
}
catch (Exception)
@@ -534,15 +533,11 @@ private UnitTestEngineConfig BuildConfig()
var isConsoleOutput = consoleOutput.IsChecked ?? false;
var isRunningIgnored = runIgnored.IsChecked ?? false;
var attempts = (retry.IsChecked ?? true) ? UnitTestEngineConfig.DefaultRepeatCount : 1;
- var filter = testFilter.Text.Trim();
- if (string.IsNullOrEmpty(filter))
- {
- filter = null;
- }
+ var query = testFilter.Text.Trim() is { Length: >0 } text ? text : null;
return new UnitTestEngineConfig
{
- Filters = filter?.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty(),
+ Query = query,
IsConsoleOutputEnabled = isConsoleOutput,
IsRunningIgnored = isRunningIgnored,
Attempts = attempts,
@@ -583,56 +578,23 @@ private static Color GetTestResultColor(TestResult testResult)
}
}
- public async Task RunTestsForInstance(object testClassInstance)
+ public async Task RunTests(CancellationToken ct, UnitTestEngineConfig config)
{
- Interlocked.Exchange(ref _cts, new CancellationTokenSource())?.Cancel(); // cancel any previous CTS
-
- testResults.Children.Clear();
+ _currentRun = new TestRun();
try
{
- try
+ SearchPredicateCollection? filters = null;
+ if (!string.IsNullOrWhiteSpace(config.Query))
{
- var testTypeInfo = BuildType(testClassInstance.GetType());
- var engineConfig = BuildConfig();
-
- await ExecuteTestsForInstance(_cts!.Token, testClassInstance, testTypeInfo, engineConfig);
+ _ = ReportMessage("Processing search query");
+ filters = SearchPredicate.ParseQuery(config.Query);
}
- catch (Exception e)
- {
- if (_currentRun is not null)
- {
- _currentRun.Failed = -1;
- }
-
- _ = ReportMessage($"Tests runner failed {e}");
- ReportTestResult("Runtime exception", TimeSpan.Zero, TestResult.Failed, e);
- ReportTestsResults();
- }
- }
- finally
- {
- await _dispatcher.RunAsync(() =>
- {
- testFilter.IsEnabled = runButton.IsEnabled = true; // Disable the testFilter to avoid SIP to re-open
- testResults.Visibility = Visibility.Visible;
- stopButton.IsEnabled = false;
- });
- }
- }
-
- public async Task RunTests(CancellationToken ct, UnitTestEngineConfig config)
- {
- _currentRun = new TestRun();
- try
- {
_ = ReportMessage("Enumerating tests");
-
- var testTypes = InitializeTests();
+ var testTypes = GetTestClasses(filters);
_ = ReportMessage("Running tests...");
-
foreach (var type in testTypes)
{
if (ct.IsCancellationRequested)
@@ -641,12 +603,9 @@ public async Task RunTests(CancellationToken ct, UnitTestEngineConfig config)
break;
}
- if (type.Type is not null)
- {
- var instance = Activator.CreateInstance(type: type.Type);
+ var instance = Activator.CreateInstance(type: type.Type);
- await ExecuteTestsForInstance(ct, instance!, type, config);
- }
+ await ExecuteTestsForInstance(ct, instance!, type, config, filters);
}
_ = ReportMessage("Tests finished running.", isRunning: false);
@@ -675,32 +634,22 @@ await _dispatcher.RunAsync(() =>
await GenerateTestResults();
}
- private static IEnumerable FilterTests(UnitTestClassInfo testClassInfo, string[]? filters)
- {
- var testClassNameContainsFilters = filters?.Any(f => testClassInfo.Type?.FullName?.Contains(f, StrComp) ?? false) ?? false;
- return testClassInfo.Tests?.
- Where(t => ((!filters?.Any()) ?? true)
- || testClassNameContainsFilters
- || (filters?.Any(f => t.DeclaringType?.FullName?.Contains(f, StrComp) ?? false) ?? false)
- || (filters?.Any(f => t.Name.Contains(f, StrComp)) ?? false))
- ?? Array.Empty();
- }
-
private async Task ExecuteTestsForInstance(
CancellationToken ct,
object instance,
UnitTestClassInfo testClassInfo,
- UnitTestEngineConfig config)
+ UnitTestEngineConfig config,
+ SearchPredicateCollection? filters)
{
using var consoleRecorder = config.IsConsoleOutputEnabled
? ConsoleOutputRecorder.Start()
: default;
- var tests = UnitTestsControl.FilterTests(testClassInfo, config.Filters)
+ var tests = GetTestMethods(testClassInfo, filters)
.Select(method => new UnitTestMethodInfo(instance, method))
.ToArray();
- if (!tests.Any() || testClassInfo.Type == null)
+ if (!tests.Any())
{
return;
}
@@ -737,7 +686,7 @@ private async Task ExecuteTestsForInstance(
}
}
- foreach (var testCase in test.GetCases())
+ foreach (var testCase in GetTestCases(test, filters))
{
if (ct.IsCancellationRequested)
{
@@ -750,7 +699,7 @@ private async Task ExecuteTestsForInstance(
async Task InvokeTestMethod(TestCase testCase)
{
- var fullTestName = string.IsNullOrWhiteSpace(testCase.DisplayName) ? testName + testCase.ToString() : testCase.DisplayName!;
+ var fullTestName = string.IsNullOrWhiteSpace(testCase.DisplayName) ? $"{testName}{testCase}" : testCase.DisplayName!;
if (_currentRun is null)
{
@@ -974,28 +923,6 @@ void Run()
}
}
- private IEnumerable InitializeTests()
- {
- var testAssemblies = AppDomain.CurrentDomain.GetAssemblies()
- .Where(x => x.GetName()?.Name?.EndsWith("Tests", StringComparison.OrdinalIgnoreCase) ?? false)
- .Concat(new[] { GetType().GetTypeInfo().Assembly })
- .Distinct();
- var types = testAssemblies.SelectMany(x => x.GetTypes());
-
- if (_ciTestGroupCache != -1)
- {
- Console.WriteLine($"Filtering with group #{_ciTestGroupCache} (Groups {_ciTestsGroupCountCache})");
- }
-
- return from type in types
- where type.GetTypeInfo().GetCustomAttribute(typeof(TestClassAttribute)) != null
- where _ciTestsGroupCountCache == -1 || (_ciTestsGroupCountCache != -1 && (UnitTestsControl.GetTypeTestGroup(type) % _ciTestsGroupCountCache) == _ciTestGroupCache)
- orderby type.Name
- let info = BuildType(type)
- where info.Type is { }
- select info;
- }
-
private static SHA1 _sha1 = SHA1.Create();
private static int GetTypeTestGroup(Type type)
@@ -1007,20 +934,20 @@ private static int GetTypeTestGroup(Type type)
return (int)BitConverter.ToUInt64(hash, 0);
}
- private static UnitTestClassInfo BuildType(Type type)
+ private static UnitTestClassInfo? BuildType(Type type)
{
try
{
return new UnitTestClassInfo(
type: type,
- tests: GetMethodsWithAttribute(type, typeof(Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute)),
- initialize: GetMethodsWithAttribute(type, typeof(Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute)).FirstOrDefault(),
- cleanup: GetMethodsWithAttribute(type, typeof(Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute)).FirstOrDefault()
+ tests: GetMethodsWithAttribute(type, typeof(TestMethodAttribute)),
+ initialize: GetMethodsWithAttribute(type, typeof(TestInitializeAttribute)).FirstOrDefault(),
+ cleanup: GetMethodsWithAttribute(type, typeof(TestCleanupAttribute)).FirstOrDefault()
);
}
catch (Exception)
{
- return new UnitTestClassInfo(null, null, null, null);
+ return null;
}
}
diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Uno.UI.RuntimeTests.Engine.Library.projitems b/src/Uno.UI.RuntimeTests.Engine.Library/Uno.UI.RuntimeTests.Engine.Library.projitems
index 3c7c839..80d22ea 100644
--- a/src/Uno.UI.RuntimeTests.Engine.Library/Uno.UI.RuntimeTests.Engine.Library.projitems
+++ b/src/Uno.UI.RuntimeTests.Engine.Library/Uno.UI.RuntimeTests.Engine.Library.projitems
@@ -9,20 +9,22 @@
Uno.UI.RuntimeTests.Engine.Library
+
+
-
+
diff --git a/src/Uno.UI.RuntimeTests.Engine.sln b/src/Uno.UI.RuntimeTests.Engine.sln
index 9a986aa..0983621 100644
--- a/src/Uno.UI.RuntimeTests.Engine.sln
+++ b/src/Uno.UI.RuntimeTests.Engine.sln
@@ -32,7 +32,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Uno.UI.RuntimeTests.Engine.
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "UWP", "UWP", "{D31D49DA-BFBD-420C-9F8E-12D77DDF3070}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.UI.RuntimeTests.Engine.Mobile", "TestApp\uwp\Uno.UI.RuntimeTests.Engine.Mobile\Uno.UI.RuntimeTests.Engine.Mobile.csproj", "{C72C5858-9953-4DEB-A551-E25BC5E7AD51}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Uno.UI.RuntimeTests.Engine.Mobile", "TestApp\uwp\Uno.UI.RuntimeTests.Engine.Mobile\Uno.UI.RuntimeTests.Engine.Mobile.csproj", "{C72C5858-9953-4DEB-A551-E25BC5E7AD51}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.UI.RuntimeTests.Engine.Uwp", "TestApp\uwp\Uno.UI.RuntimeTests.Engine.UWP\Uno.UI.RuntimeTests.Engine.Uwp.csproj", "{9804C1B9-A958-4A09-B975-AAECF08CFEE1}"
EndProject
@@ -192,6 +192,22 @@ Global
{7BD72040-D89A-4DE8-8713-5A08068ADBCE}.Release|x64.Build.0 = Release|Any CPU
{7BD72040-D89A-4DE8-8713-5A08068ADBCE}.Release|x86.ActiveCfg = Release|Any CPU
{7BD72040-D89A-4DE8-8713-5A08068ADBCE}.Release|x86.Build.0 = Release|Any CPU
+ {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Debug|arm64.ActiveCfg = Debug|Any CPU
+ {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Debug|arm64.Build.0 = Debug|Any CPU
+ {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Debug|x64.Build.0 = Debug|Any CPU
+ {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Debug|x86.Build.0 = Debug|Any CPU
+ {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Release|arm64.ActiveCfg = Release|Any CPU
+ {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Release|arm64.Build.0 = Release|Any CPU
+ {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Release|x64.ActiveCfg = Release|Any CPU
+ {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Release|x64.Build.0 = Release|Any CPU
+ {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Release|x86.ActiveCfg = Release|Any CPU
+ {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Release|x86.Build.0 = Release|Any CPU
{9804C1B9-A958-4A09-B975-AAECF08CFEE1}.Debug|Any CPU.ActiveCfg = Debug|x64
{9804C1B9-A958-4A09-B975-AAECF08CFEE1}.Debug|Any CPU.Build.0 = Debug|x64
{9804C1B9-A958-4A09-B975-AAECF08CFEE1}.Debug|Any CPU.Deploy.0 = Debug|x64
@@ -318,6 +334,8 @@ Global
Uno.UI.RuntimeTests.Engine.Library\Uno.UI.RuntimeTests.Engine.Library.projitems*{9804c1b9-a958-4a09-b975-aaecf08cfee1}*SharedItemsImports = 4
TestApp\shared\Uno.UI.RuntimeTests.Engine.Shared.projitems*{a5b8155a-118f-4794-b551-c6f3cf7e5411}*SharedItemsImports = 5
Uno.UI.RuntimeTests.Engine.Library\Uno.UI.RuntimeTests.Engine.Library.projitems*{a5b8155a-118f-4794-b551-c6f3cf7e5411}*SharedItemsImports = 5
+ TestApp\shared\Uno.UI.RuntimeTests.Engine.Shared.projitems*{c72c5858-9953-4deb-a551-e25bc5e7ad51}*SharedItemsImports = 5
+ Uno.UI.RuntimeTests.Engine.Library\Uno.UI.RuntimeTests.Engine.Library.projitems*{c72c5858-9953-4deb-a551-e25bc5e7ad51}*SharedItemsImports = 5
TestApp\shared\Uno.UI.RuntimeTests.Engine.Shared.projitems*{dec0dfbd-2926-492b-be22-2158438556e3}*SharedItemsImports = 5
Uno.UI.RuntimeTests.Engine.Library\Uno.UI.RuntimeTests.Engine.Library.projitems*{dec0dfbd-2926-492b-be22-2158438556e3}*SharedItemsImports = 5
Uno.UI.RuntimeTests.Engine.Library\Uno.UI.RuntimeTests.Engine.Library.projitems*{e3f4af60-8456-4ed6-9992-303cdad44e0d}*SharedItemsImports = 13