From e4d8bb3341d100b38c5f7c5ae6500b709654d9f1 Mon Sep 17 00:00:00 2001 From: xiaoy312 Date: Mon, 15 May 2023 03:48:23 -0400 Subject: [PATCH] refactor(wip): filter, more tags; (fixme: SplitWithIgnore) --- src/TestApp/shared/EngineFeatureTests.cs | 45 +++++ .../UI/UnitTestClassInfo.cs | 13 +- .../UI/UnitTestMethodInfo.cs | 41 +--- .../UI/UnitTestsControl.Filtering.cs | 175 ++++++++++++++---- .../UI/UnitTestsControl.cs | 101 +++------- 5 files changed, 217 insertions(+), 158 deletions(-) diff --git a/src/TestApp/shared/EngineFeatureTests.cs b/src/TestApp/shared/EngineFeatureTests.cs index 3dc96e1..fc1da93 100644 --- a/src/TestApp/shared/EngineFeatureTests.cs +++ b/src/TestApp/shared/EngineFeatureTests.cs @@ -37,5 +37,50 @@ public async Task When_Test_ContentHelper() 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/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/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 index ad9fe75..cb3c76f 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsControl.Filtering.cs +++ b/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsControl.Filtering.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Data; using System.Diagnostics.CodeAnalysis; using System.Linq; @@ -11,66 +12,131 @@ 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 static IEnumerable FilterTests(UnitTestClassInfo testClassInfo, string? query) + private IEnumerable GetTestClasses(SearchPredicateCollection? filters) { - var tests = testClassInfo.Tests?.AsEnumerable() ?? Array.Empty(); - foreach (var filter in SearchPredicate.ParseQuery(query)) - { - // chain filters with AND logic - tests = tests.Where(x => - filter.Exclusion ^ // use xor to flip the result based on Exclusion - filter.Tag?.ToLowerInvariant() switch - { - "class" => MatchClassName(x, filter.Text), - "displayname" => MatchDisplayName(x, filter.Text), - "method" => MatchMethodName(x, filter.Text), + 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); - _ => MatchClassName(x, filter.Text) || MatchMethodName(x, filter.Text), - } - ); + if (_ciTestGroupCache != -1) + { + Console.WriteLine($"Filtering with group #{_ciTestGroupCache} (Groups {_ciTestsGroupCountCache})"); } - bool MatchClassName(MethodInfo x, string value) => x.DeclaringType?.Name.Contains(value, InvariantCultureIgnoreCase) ?? false; - bool MatchMethodName(MethodInfo x, string value) => x.Name.Contains(value, InvariantCultureIgnoreCase); - bool MatchDisplayName(MethodInfo x, string value) => - // fixme: since we are returning MethodInfo for match, there is no way to specify - // which of the [DataRow] or which row within [DynamicData] without refactoring. - // fixme: support [DynamicData] - x.GetCustomAttributes().Any(y => y.DisplayName.Contains(value, InvariantCultureIgnoreCase)); - - return tests; + 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) + .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(); - // fixme: domain specific configuration should be injectable - private static readonly IReadOnlyDictionary NamespaceAliases = + private static readonly IReadOnlyDictionary NamespaceAliases = // aliases for tag new Dictionary(StringComparer.InvariantCultureIgnoreCase) { - ["c"] = "class", - ["m"] = "method", - ["d"] = "display_name", + ["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 SearchPredicate[] ParseQuery(string? query) + public static SearchPredicateCollection? ParseQuery(string? query) { - if (string.IsNullOrWhiteSpace(query)) return Array.Empty(); + if (string.IsNullOrWhiteSpace(query)) return null; - return query!.SplitWithIgnore(' ', @""".*?(?() // trim null .Where(x => x.Text.Length > 0) // ignore empty tag query eg: "c:" - .ToArray(); + .ToArray() + ); } public static SearchPredicate? ParseFragment(string criteria) @@ -84,8 +150,15 @@ public static SearchPredicate[] ParseQuery(string? query) text = text.Substring(1); } var tag = default(string?); - if (text.Split(':', 2) is { Length: 2 } tagParts) + 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]; } @@ -96,8 +169,9 @@ public static SearchPredicate[] ParseQuery(string? query) return new(raw, text, exclusion, tag, parts); } - public bool IsMatch(string input) => Parts - .Any(x => (x.MatchStart, x.MatchEnd) switch + public bool IsMatch(string input) => + Exclusion ^ // use xor to flip the result based on Exclusion + Parts.Any(x => (x.MatchStart, x.MatchEnd) switch { (true, false) => input.StartsWith(x.Text), (false, true) => input.EndsWith(x.Text), @@ -166,6 +240,35 @@ public static SearchPredicatePart Parse(string part) 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 c810020..659d258 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsControl.cs +++ b/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsControl.cs @@ -533,7 +533,7 @@ private UnitTestEngineConfig BuildConfig() var isConsoleOutput = consoleOutput.IsChecked ?? false; var isRunningIgnored = runIgnored.IsChecked ?? false; var attempts = (retry.IsChecked ?? true) ? UnitTestEngineConfig.DefaultRepeatCount : 1; - var query = testFilter.Text.Trim() is { Length: >0 } text ? text: null; + var query = testFilter.Text.Trim() is { Length: >0 } text ? text : null; return new UnitTestEngineConfig { @@ -578,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) @@ -636,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); @@ -674,22 +638,23 @@ 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 = FilterTests(testClassInfo, config.Query) + var tests = GetTestMethods(testClassInfo, filters) .Select(method => new UnitTestMethodInfo(instance, method)) .ToArray(); - if (!tests.Any() || testClassInfo.Type == null) + if (!tests.Any()) { return; } - ReportTestClass(testClassInfo.Type!.GetTypeInfo()); + ReportTestClass(testClassInfo.Type.GetTypeInfo()); _ = ReportMessage($"Running {tests.Length} test methods"); foreach (var test in tests) @@ -721,7 +686,7 @@ private async Task ExecuteTestsForInstance( } } - foreach (var testCase in test.GetCases()) + foreach (var testCase in GetTestCases(test, filters)) { if (ct.IsCancellationRequested) { @@ -734,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) { @@ -958,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) @@ -991,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; } }