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