diff --git a/src/Microsoft.TestPlatform.Filter.Source/Condition.cs b/src/Microsoft.TestPlatform.Filter.Source/Condition.cs index 322b6a016a..12b0556273 100644 --- a/src/Microsoft.TestPlatform.Filter.Source/Condition.cs +++ b/src/Microsoft.TestPlatform.Filter.Source/Condition.cs @@ -71,6 +71,11 @@ internal sealed class Condition /// public const Operation DefaultOperation = Operation.Contains; + /// + /// Reserved filter value that matches tests with no value for a given property (uncategorized). + /// + internal const string NoneFilterValue = "None"; + #if !IS_VSTEST_REPO private const string TestCaseFilterFormatException = "Incorrect format for TestCaseFilter {0}. Specify the correct format and try again. Note that the incorrect format can lead to no test getting executed."; @@ -103,6 +108,13 @@ internal Condition(string name, Operation operation, string value) private bool EvaluateEqualOperation(string[]? multiValue) { + // Reserved keyword: "None" matches tests with no value for this property (uncategorized). + if (multiValue is null or { Length: 0 } + && string.Equals(Value, NoneFilterValue, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + // if any value in multi-valued property matches 'this.Value', for Equal to evaluate true. if (multiValue != null) { diff --git a/src/Microsoft.TestPlatform.Filter.Source/FastFilter.cs b/src/Microsoft.TestPlatform.Filter.Source/FastFilter.cs index 379f3addf4..7c90a0b67a 100644 --- a/src/Microsoft.TestPlatform.Filter.Source/FastFilter.cs +++ b/src/Microsoft.TestPlatform.Filter.Source/FastFilter.cs @@ -82,9 +82,18 @@ internal bool Evaluate(Func propertyValueProvider) bool matched = false; foreach (var name in FilterProperties.Keys) { - // If there is no value corresponding to given name, treat it as unmatched. + // Reserved keyword: "None" matches tests with no value for this property (uncategorized). + bool hasNoneFilter = FilterProperties[name].Contains(Condition.NoneFilterValue); + + // If there is no value corresponding to given name, treat it as unmatched unless filtering for "None". if (!TryGetPropertyValue(name, propertyValueProvider, out var singleValue, out var multiValues)) { + if (hasNoneFilter) + { + matched = true; + break; + } + continue; } @@ -93,11 +102,16 @@ internal bool Evaluate(Func propertyValueProvider) var value = PropertyValueRegex == null ? singleValue : ApplyRegex(singleValue); matched = value != null && FilterProperties[name].Contains(value); } - else + else if (multiValues is { Length: > 0 }) { var values = PropertyValueRegex == null ? multiValues : multiValues?.Select(value => ApplyRegex(value)); matched = values?.Any(result => result != null && FilterProperties[name].Contains(result)) == true; } + else if (hasNoneFilter) + { + // Empty array matches "None" filter (uncategorized). + matched = true; + } if (matched) { diff --git a/test/Microsoft.TestPlatform.Acceptance.IntegrationTests/TestCaseFilterTests.cs b/test/Microsoft.TestPlatform.Acceptance.IntegrationTests/TestCaseFilterTests.cs index 72084bc4ea..0afd627f46 100644 --- a/test/Microsoft.TestPlatform.Acceptance.IntegrationTests/TestCaseFilterTests.cs +++ b/test/Microsoft.TestPlatform.Acceptance.IntegrationTests/TestCaseFilterTests.cs @@ -178,4 +178,43 @@ public void DiscoverMstestV1TestsWithAndOperatorTrait(RunnerInfo runnerInfo) ValidateTestsNotDiscovered(listOfNotDiscoveredTests); } + [TestMethod] + [NetFullTargetFrameworkDataSourceAttribute(inIsolation: true, inProcess: true)] + [NetCoreTargetFrameworkDataSource] + public void RunSelectedTestsWithNoneTestCategoryFilterMatchesUncategorizedTests(RunnerInfo runnerInfo) + { + SetTestEnvironment(_testEnvironment, runnerInfo); + + var arguments = PrepareArguments( + GetSampleTestAssembly(), + GetTestAdapterPath(), + string.Empty, FrameworkArgValue, + runnerInfo.InIsolationValue, resultsDirectory: TempDirectory.Path); + // "None" is a reserved keyword that matches tests without any TestCategory attribute. + // In SimpleTestProject: PassingTest (no category) and SkippingTest (no category, ignored). + // FailingTest has TestCategory("CategoryA") and should NOT be matched. + arguments = string.Concat(arguments, " /TestCaseFilter:\"TestCategory=None\""); + InvokeVsTest(arguments); + ValidateSummaryStatus(1, 0, 1); + } + + [TestMethod] + [NetFullTargetFrameworkDataSourceAttribute(inIsolation: true, inProcess: true)] + [NetCoreTargetFrameworkDataSource] + public void RunSelectedTestsWithNoneTestCategoryNotEqualFilterMatchesCategorizedTests(RunnerInfo runnerInfo) + { + SetTestEnvironment(_testEnvironment, runnerInfo); + + var arguments = PrepareArguments( + GetSampleTestAssembly(), + GetTestAdapterPath(), + string.Empty, FrameworkArgValue, + runnerInfo.InIsolationValue, resultsDirectory: TempDirectory.Path); + // NotEqual to "None" should match tests WITH categories. + // In SimpleTestProject: only FailingTest has TestCategory("CategoryA"). + arguments = string.Concat(arguments, " /TestCaseFilter:\"TestCategory!=None\""); + InvokeVsTest(arguments); + ValidateSummaryStatus(0, 1, 0); + } + } diff --git a/test/Microsoft.TestPlatform.Common.UnitTests/Filtering/ConditionTests.cs b/test/Microsoft.TestPlatform.Common.UnitTests/Filtering/ConditionTests.cs index 02c65418b3..af2d454e25 100644 --- a/test/Microsoft.TestPlatform.Common.UnitTests/Filtering/ConditionTests.cs +++ b/test/Microsoft.TestPlatform.Common.UnitTests/Filtering/ConditionTests.cs @@ -235,4 +235,134 @@ public void TokenizeConditionShouldHandleSingleBangAtEnd() Assert.AreEqual("FullyQualifiedName", tokens[0]); Assert.AreEqual("!", tokens[1]); } + + #region None filter value tests (uncategorized tests support) + + [TestMethod] + public void ParseNoneValueShouldCreateCondition() + { + Condition condition = Condition.Parse("TestCategory=None"); + + Assert.AreEqual("TestCategory", condition.Name); + Assert.AreEqual(Operation.Equal, condition.Operation); + Assert.AreEqual("None", condition.Value); + } + + [TestMethod] + public void ParseNoneValueWithNotEqualShouldCreateCondition() + { + Condition condition = Condition.Parse("TestCategory!=None"); + + Assert.AreEqual("TestCategory", condition.Name); + Assert.AreEqual(Operation.NotEqual, condition.Operation); + Assert.AreEqual("None", condition.Value); + } + + [TestMethod] + public void EvaluateNoneEqualWithNullPropertyShouldReturnTrue() + { + var condition = new Condition("TestCategory", Operation.Equal, "None"); + bool result = condition.Evaluate(propertyName => null); + + Assert.IsTrue(result); + } + + [TestMethod] + public void EvaluateNoneEqualWithEmptyArrayShouldReturnTrue() + { + var condition = new Condition("TestCategory", Operation.Equal, "None"); + bool result = condition.Evaluate(propertyName => Array.Empty()); + + Assert.IsTrue(result); + } + + [TestMethod] + public void EvaluateNoneEqualWithNonEmptyArrayShouldReturnFalse() + { + var condition = new Condition("TestCategory", Operation.Equal, "None"); + bool result = condition.Evaluate(propertyName => new[] { "CategoryA" }); + + Assert.IsFalse(result); + } + + [TestMethod] + public void EvaluateNoneEqualIsCaseInsensitive() + { + var condition = new Condition("TestCategory", Operation.Equal, "none"); + Assert.IsTrue(condition.Evaluate(propertyName => null)); + + var condition2 = new Condition("TestCategory", Operation.Equal, "NONE"); + Assert.IsTrue(condition2.Evaluate(propertyName => null)); + } + + [TestMethod] + public void EvaluateNoneNotEqualWithNullPropertyShouldReturnFalse() + { + var condition = new Condition("TestCategory", Operation.NotEqual, "None"); + bool result = condition.Evaluate(propertyName => null); + + Assert.IsFalse(result); + } + + [TestMethod] + public void EvaluateNoneNotEqualWithEmptyArrayShouldReturnFalse() + { + var condition = new Condition("TestCategory", Operation.NotEqual, "None"); + bool result = condition.Evaluate(propertyName => Array.Empty()); + + Assert.IsFalse(result); + } + + [TestMethod] + public void EvaluateNoneNotEqualWithNonEmptyArrayShouldReturnTrue() + { + var condition = new Condition("TestCategory", Operation.NotEqual, "None"); + bool result = condition.Evaluate(propertyName => new[] { "CategoryA" }); + + Assert.IsTrue(result); + } + + [TestMethod] + public void EvaluateNonNoneValueShouldStillWorkNormally() + { + var condition = new Condition("TestCategory", Operation.Equal, "CategoryA"); + Assert.IsTrue(condition.Evaluate(propertyName => new[] { "CategoryA" })); + Assert.IsFalse(condition.Evaluate(propertyName => new[] { "CategoryB" })); + Assert.IsFalse(condition.Evaluate(propertyName => null)); + } + + [TestMethod] + public void EvaluateNoneEqualWithExplicitNoneCategoryShouldReturnTrue() + { + // A test with [TestCategory("None")] should also match TestCategory=None. + // "None" is reserved for uncategorized, but tests that literally use it + // are included as well (by design, to avoid silent mismatches). + var condition = new Condition("TestCategory", Operation.Equal, "None"); + bool result = condition.Evaluate(propertyName => new[] { "None" }); + + Assert.IsTrue(result); + } + + [TestMethod] + public void EvaluateNoneNotEqualWithExplicitNoneCategoryShouldReturnFalse() + { + // A test with [TestCategory("None")] should NOT match TestCategory!=None. + var condition = new Condition("TestCategory", Operation.NotEqual, "None"); + bool result = condition.Evaluate(propertyName => new[] { "None" }); + + Assert.IsFalse(result); + } + + [TestMethod] + public void EvaluateNoneEqualWithExplicitNoneCategoryAmongOthersShouldReturnTrue() + { + // A test with [TestCategory("None")] and [TestCategory("CategoryA")] + // should match TestCategory=None because one of the values equals "None". + var condition = new Condition("TestCategory", Operation.Equal, "None"); + bool result = condition.Evaluate(propertyName => new[] { "None", "CategoryA" }); + + Assert.IsTrue(result); + } + + #endregion } diff --git a/test/Microsoft.TestPlatform.Common.UnitTests/Filtering/FastFilterTests.cs b/test/Microsoft.TestPlatform.Common.UnitTests/Filtering/FastFilterTests.cs index 7c1bad5344..243a7e072c 100644 --- a/test/Microsoft.TestPlatform.Common.UnitTests/Filtering/FastFilterTests.cs +++ b/test/Microsoft.TestPlatform.Common.UnitTests/Filtering/FastFilterTests.cs @@ -442,4 +442,71 @@ public void MultiplePropertyNamesNotEqualAnd() _ => null, })); } + + #region None filter value tests (uncategorized tests support) + + [TestMethod] + public void FastFilterWithNoneEqualShouldMatchNullProperty() + { + var filterExpressionWrapper = new FilterExpressionWrapper("TestCategory=None"); + var fastFilter = filterExpressionWrapper.FastFilter; + + Assert.IsNotNull(fastFilter); + Assert.IsFalse(fastFilter.IsFilteredOutWhenMatched); + + // Null property value means uncategorized - should match. + Assert.IsTrue(fastFilter.Evaluate(s => null)); + // Empty array means uncategorized - should match. + Assert.IsTrue(fastFilter.Evaluate(s => Array.Empty())); + // Non-empty category - should not match. + Assert.IsFalse(fastFilter.Evaluate(s => new[] { "CategoryA" })); + } + + [TestMethod] + public void FastFilterWithNoneEqualOrSpecificCategoryShouldMatchBoth() + { + var filterExpressionWrapper = new FilterExpressionWrapper("TestCategory=None|TestCategory=CategoryA"); + var fastFilter = filterExpressionWrapper.FastFilter; + + Assert.IsNotNull(fastFilter); + Assert.IsFalse(fastFilter.IsFilteredOutWhenMatched); + + // Null property value means uncategorized - should match. + Assert.IsTrue(fastFilter.Evaluate(s => null)); + // CategoryA - should match. + Assert.IsTrue(fastFilter.Evaluate(s => new[] { "CategoryA" })); + // CategoryB - should not match. + Assert.IsFalse(fastFilter.Evaluate(s => new[] { "CategoryB" })); + } + + [TestMethod] + public void FastFilterWithNoneNotEqualShouldMatchCategorizedTests() + { + var filterExpressionWrapper = new FilterExpressionWrapper("TestCategory!=None"); + var fastFilter = filterExpressionWrapper.FastFilter; + + Assert.IsNotNull(fastFilter); + Assert.IsTrue(fastFilter.IsFilteredOutWhenMatched); + + // Null property value means uncategorized - should NOT match (filtered out). + Assert.IsFalse(fastFilter.Evaluate(s => null)); + // Non-empty category - should match (not filtered out). + Assert.IsTrue(fastFilter.Evaluate(s => new[] { "CategoryA" })); + } + + [TestMethod] + public void FastFilterWithNoneEqualShouldAlsoMatchExplicitNoneCategory() + { + var filterExpressionWrapper = new FilterExpressionWrapper("TestCategory=None"); + var fastFilter = filterExpressionWrapper.FastFilter; + + Assert.IsNotNull(fastFilter); + + // A test with [TestCategory("None")] should also match, since "None" is in the filter set. + Assert.IsTrue(fastFilter.Evaluate(s => new[] { "None" })); + // Case-insensitive: "none" should also match. + Assert.IsTrue(fastFilter.Evaluate(s => new[] { "none" })); + } + + #endregion }