diff --git a/src/Common/EnumerableExtensions.cs b/src/Common/EnumerableExtensions.cs index f34294f7d..5e6a93a8b 100644 --- a/src/Common/EnumerableExtensions.cs +++ b/src/Common/EnumerableExtensions.cs @@ -1,4 +1,6 @@ -using System.Diagnostics.CodeAnalysis; +using System; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; namespace Moq.Analyzers.Common; @@ -16,6 +18,11 @@ internal static class EnumerableExtensions [SuppressMessage("Performance", "ECS0900:Minimize boxing and unboxing", Justification = "Should revisit. Suppressing for now to unblock refactor.")] public static TSource? DefaultIfNotSingle(this ImmutableArray source, Func predicate) { + if (predicate == null) + { + throw new ArgumentNullException(nameof(predicate)); + } + return source.AsEnumerable().DefaultIfNotSingle(predicate); } @@ -34,11 +41,18 @@ internal static class EnumerableExtensions /// public static TSource? DefaultIfNotSingle(this IEnumerable source, Func predicate) { + ValidateDefaultIfNotSingleArguments(source, predicate); + bool isFound = false; TSource? item = default; - foreach (TSource element in source.Where(predicate)) + foreach (TSource element in source) { + if (!predicate(element)) + { + continue; + } + if (isFound) { // We already found an element, thus there's multiple matches; return default. @@ -51,4 +65,18 @@ internal static class EnumerableExtensions return item; } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void ValidateDefaultIfNotSingleArguments(IEnumerable source, Func predicate) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (predicate == null) + { + throw new ArgumentNullException(nameof(predicate)); + } + } } diff --git a/tests/Moq.Analyzers.Benchmarks/DefaultIfNotSingleBaseline.cs b/tests/Moq.Analyzers.Benchmarks/DefaultIfNotSingleBaseline.cs new file mode 100644 index 000000000..a5e8411e7 --- /dev/null +++ b/tests/Moq.Analyzers.Benchmarks/DefaultIfNotSingleBaseline.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Immutable; +using System.Linq; + +namespace Moq.Analyzers.Benchmarks; + +#pragma warning disable ECS0900 // Minimize boxing and unboxing +internal static class DefaultIfNotSingleBaseline +{ + public static T? DefaultIfNotSingleBaselineMethod(this IEnumerable source, Func predicate) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(predicate); + bool found = false; + T? item = default; + foreach (T element in source.Where(predicate)) + { + if (found) + { + return default; + } + + found = true; + item = element; + } + + return item; + } + + public static T? DefaultIfNotSingleBaselineMethod(this IEnumerable source) + => source.DefaultIfNotSingleBaselineMethod(static _ => true); + + public static T? DefaultIfNotSingleBaselineMethod(this ImmutableArray source, Func predicate) + => source.AsEnumerable().DefaultIfNotSingleBaselineMethod(predicate); +#pragma warning restore ECS0900 +} diff --git a/tests/Moq.Analyzers.Benchmarks/DefaultIfNotSingleEnumerableBenchmarks.cs b/tests/Moq.Analyzers.Benchmarks/DefaultIfNotSingleEnumerableBenchmarks.cs new file mode 100644 index 000000000..94a5d1751 --- /dev/null +++ b/tests/Moq.Analyzers.Benchmarks/DefaultIfNotSingleEnumerableBenchmarks.cs @@ -0,0 +1,17 @@ +using System.Linq; +using BenchmarkDotNet.Attributes; + +namespace Moq.Analyzers.Benchmarks; + +[MemoryDiagnoser] +[InProcess] +public class DefaultIfNotSingleEnumerableBenchmarks +{ + private readonly IEnumerable _source = Enumerable.Range(0, 100).ToArray(); + + [Benchmark(Baseline = true)] + public int? Baseline() => _source.DefaultIfNotSingleBaselineMethod(x => x == 50); + + [Benchmark] + public int? Optimized() => _source.DefaultIfNotSingleOptimizedMethod(x => x == 50); +} diff --git a/tests/Moq.Analyzers.Benchmarks/DefaultIfNotSingleEnumerableNoPredicateBenchmarks.cs b/tests/Moq.Analyzers.Benchmarks/DefaultIfNotSingleEnumerableNoPredicateBenchmarks.cs new file mode 100644 index 000000000..19a5460ee --- /dev/null +++ b/tests/Moq.Analyzers.Benchmarks/DefaultIfNotSingleEnumerableNoPredicateBenchmarks.cs @@ -0,0 +1,17 @@ +using System.Linq; +using BenchmarkDotNet.Attributes; + +namespace Moq.Analyzers.Benchmarks; + +[MemoryDiagnoser] +[InProcess] +public class DefaultIfNotSingleEnumerableNoPredicateBenchmarks +{ + private readonly IEnumerable _source = new[] { 0 }; + + [Benchmark(Baseline = true)] + public int? Baseline() => _source.DefaultIfNotSingleBaselineMethod(); + + [Benchmark] + public int? Optimized() => _source.DefaultIfNotSingleOptimizedMethod(); +} diff --git a/tests/Moq.Analyzers.Benchmarks/DefaultIfNotSingleImmutableArrayBenchmarks.cs b/tests/Moq.Analyzers.Benchmarks/DefaultIfNotSingleImmutableArrayBenchmarks.cs new file mode 100644 index 000000000..b00ea3b79 --- /dev/null +++ b/tests/Moq.Analyzers.Benchmarks/DefaultIfNotSingleImmutableArrayBenchmarks.cs @@ -0,0 +1,20 @@ +using System.Collections.Immutable; +using System.Linq; +using BenchmarkDotNet.Attributes; + +namespace Moq.Analyzers.Benchmarks; + +[MemoryDiagnoser] +[InProcess] +public class DefaultIfNotSingleImmutableArrayBenchmarks +{ + private readonly ImmutableArray _source = ImmutableArray.CreateRange(Enumerable.Range(0, 100)); + +#pragma warning disable ECS0900 // Minimize boxing and unboxing + [Benchmark(Baseline = true)] + public int? Baseline() => _source.DefaultIfNotSingleBaselineMethod(x => x == 50); + + [Benchmark] + public int? Optimized() => _source.DefaultIfNotSingleOptimizedMethod(x => x == 50); +#pragma warning restore ECS0900 +} diff --git a/tests/Moq.Analyzers.Benchmarks/DefaultIfNotSingleOptimized.cs b/tests/Moq.Analyzers.Benchmarks/DefaultIfNotSingleOptimized.cs new file mode 100644 index 000000000..e5656e016 --- /dev/null +++ b/tests/Moq.Analyzers.Benchmarks/DefaultIfNotSingleOptimized.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Immutable; +using System.Linq; + +namespace Moq.Analyzers.Benchmarks; + +#pragma warning disable ECS0900 // Minimize boxing and unboxing +internal static class DefaultIfNotSingleOptimized +{ + public static T? DefaultIfNotSingleOptimizedMethod(this IEnumerable source, Func predicate) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(predicate); + bool found = false; + T? item = default; + foreach (T element in source) + { + if (!predicate(element)) + { + continue; + } + + if (found) + { + return default; + } + + found = true; + item = element; + } + + return item; + } + + public static T? DefaultIfNotSingleOptimizedMethod(this IEnumerable source) + => source.DefaultIfNotSingleOptimizedMethod(static _ => true); + + public static T? DefaultIfNotSingleOptimizedMethod(this ImmutableArray source, Func predicate) + => source.AsEnumerable().DefaultIfNotSingleOptimizedMethod(predicate); +#pragma warning restore ECS0900 +} diff --git a/tests/Moq.Analyzers.Test/Common/DiagnosticEditPropertiesTests.cs b/tests/Moq.Analyzers.Test/Common/DiagnosticEditPropertiesTests.cs new file mode 100644 index 000000000..68262cef9 --- /dev/null +++ b/tests/Moq.Analyzers.Test/Common/DiagnosticEditPropertiesTests.cs @@ -0,0 +1,70 @@ +using System.Collections.Immutable; +using Moq.Analyzers.Common; +using Xunit; + +namespace Moq.Analyzers.Test.Common; + +public class DiagnosticEditPropertiesTests +{ + [Fact] + public void ToImmutableDictionary_RoundTripsProperties() + { + DiagnosticEditProperties props = new DiagnosticEditProperties { TypeOfEdit = DiagnosticEditProperties.EditType.Insert, EditPosition = 2 }; + ImmutableDictionary dict = props.ToImmutableDictionary(); + Assert.Equal("Insert", dict[DiagnosticEditProperties.EditTypeKey]); + Assert.Equal("2", dict[DiagnosticEditProperties.EditPositionKey]); + } + + [Fact] + public void TryGetFromImmutableDictionary_Succeeds_WithValidDictionary() + { + ImmutableDictionary dict = ImmutableDictionary.Empty + .Add(DiagnosticEditProperties.EditTypeKey, "Replace") + .Add(DiagnosticEditProperties.EditPositionKey, "5"); + bool result = DiagnosticEditProperties.TryGetFromImmutableDictionary(dict, out DiagnosticEditProperties? props); + Assert.True(result); + Assert.NotNull(props); + Assert.Equal(DiagnosticEditProperties.EditType.Replace, props!.TypeOfEdit); + Assert.Equal(5, props.EditPosition); + } + + [Fact] + public void TryGetFromImmutableDictionary_Fails_WhenEditTypeKeyMissing() + { + ImmutableDictionary dict = ImmutableDictionary.Empty.Add(DiagnosticEditProperties.EditPositionKey, "1"); + bool result = DiagnosticEditProperties.TryGetFromImmutableDictionary(dict, out DiagnosticEditProperties? props); + Assert.False(result); + Assert.Null(props); + } + + [Fact] + public void TryGetFromImmutableDictionary_Fails_WhenEditPositionKeyMissing() + { + ImmutableDictionary dict = ImmutableDictionary.Empty.Add(DiagnosticEditProperties.EditTypeKey, "Insert"); + bool result = DiagnosticEditProperties.TryGetFromImmutableDictionary(dict, out DiagnosticEditProperties? props); + Assert.False(result); + Assert.Null(props); + } + + [Fact] + public void TryGetFromImmutableDictionary_Fails_WhenEditTypeInvalid() + { + ImmutableDictionary dict = ImmutableDictionary.Empty + .Add(DiagnosticEditProperties.EditTypeKey, "NotAType") + .Add(DiagnosticEditProperties.EditPositionKey, "1"); + bool result = DiagnosticEditProperties.TryGetFromImmutableDictionary(dict, out DiagnosticEditProperties? props); + Assert.False(result); + Assert.Null(props); + } + + [Fact] + public void TryGetFromImmutableDictionary_Fails_WhenEditPositionInvalid() + { + ImmutableDictionary dict = ImmutableDictionary.Empty + .Add(DiagnosticEditProperties.EditTypeKey, "Insert") + .Add(DiagnosticEditProperties.EditPositionKey, "notAnInt"); + bool result = DiagnosticEditProperties.TryGetFromImmutableDictionary(dict, out DiagnosticEditProperties? props); + Assert.False(result); + Assert.Null(props); + } +} diff --git a/tests/Moq.Analyzers.Test/Common/EnumerableExtensionsTests.cs b/tests/Moq.Analyzers.Test/Common/EnumerableExtensionsTests.cs index e663e8e23..cadda44ac 100644 --- a/tests/Moq.Analyzers.Test/Common/EnumerableExtensionsTests.cs +++ b/tests/Moq.Analyzers.Test/Common/EnumerableExtensionsTests.cs @@ -1,20 +1,26 @@ -namespace Moq.Analyzers.Test.Common; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using Xunit; + +namespace Moq.Analyzers.Test.Common; public class EnumerableExtensionsTests { [Fact] public void DefaultIfNotSingle_ReturnsNull_WhenSourceIsEmpty() { - IEnumerable source = []; - int result = source.DefaultIfNotSingle(); - Assert.Equal(0, result); + IEnumerable source = []; + object? result = source.DefaultIfNotSingle(); + Assert.Null(result); } [Fact] public void DefaultIfNotSingle_ReturnsElement_WhenSourceContainsSingleElement() { int[] source = [42]; - int result = source.DefaultIfNotSingle(); + int? result = source.DefaultIfNotSingle(); Assert.Equal(42, result); } @@ -22,7 +28,7 @@ public void DefaultIfNotSingle_ReturnsElement_WhenSourceContainsSingleElement() public void DefaultIfNotSingle_ReturnsNull_WhenSourceContainsMultipleElements() { int[] source = [1, 2, 3]; - int result = source.DefaultIfNotSingle(); + int? result = source.DefaultIfNotSingle(); Assert.Equal(0, result); } @@ -30,7 +36,7 @@ public void DefaultIfNotSingle_ReturnsNull_WhenSourceContainsMultipleElements() public void DefaultIfNotSingle_WithPredicate_ReturnsNull_WhenNoElementsMatch() { int[] source = [1, 2, 3]; - int result = source.DefaultIfNotSingle(x => x > 10); + int? result = source.DefaultIfNotSingle(x => x > 10); Assert.Equal(0, result); } @@ -38,7 +44,7 @@ public void DefaultIfNotSingle_WithPredicate_ReturnsNull_WhenNoElementsMatch() public void DefaultIfNotSingle_WithPredicate_ReturnsElement_WhenOnlyOneMatches() { int[] source = [1, 2, 3]; - int result = source.DefaultIfNotSingle(x => x == 2); + int? result = source.DefaultIfNotSingle(x => x == 2); Assert.Equal(2, result); } @@ -46,7 +52,7 @@ public void DefaultIfNotSingle_WithPredicate_ReturnsElement_WhenOnlyOneMatches() public void DefaultIfNotSingle_WithPredicate_ReturnsNull_WhenMultipleElementsMatch() { int[] source = [1, 2, 2, 3]; - int result = source.DefaultIfNotSingle(x => x > 1); + int? result = source.DefaultIfNotSingle(x => x > 1); Assert.Equal(0, result); } @@ -54,7 +60,7 @@ public void DefaultIfNotSingle_WithPredicate_ReturnsNull_WhenMultipleElementsMat public void DefaultIfNotSingle_ImmutableArray_ReturnsNull_WhenEmpty() { ImmutableArray source = ImmutableArray.Empty; - int result = source.DefaultIfNotSingle(x => x > 0); + int? result = source.DefaultIfNotSingle(x => x > 0); Assert.Equal(0, result); } @@ -62,7 +68,7 @@ public void DefaultIfNotSingle_ImmutableArray_ReturnsNull_WhenEmpty() public void DefaultIfNotSingle_ImmutableArray_ReturnsElement_WhenSingleMatch() { ImmutableArray source = [.. new[] { 5, 10, 15 }]; - int result = source.DefaultIfNotSingle(x => x == 10); + int? result = source.DefaultIfNotSingle(x => x == 10); Assert.Equal(10, result); } @@ -70,7 +76,103 @@ public void DefaultIfNotSingle_ImmutableArray_ReturnsElement_WhenSingleMatch() public void DefaultIfNotSingle_ImmutableArray_ReturnsNull_WhenMultipleMatches() { ImmutableArray source = [.. new[] { 5, 10, 10, 15 }]; - int result = source.DefaultIfNotSingle(x => x > 5); + int? result = source.DefaultIfNotSingle(x => x > 5); + Assert.Equal(0, result); + } + + [Fact] + public void DefaultIfNotSingle_StopsEnumeratingAfterSecondMatch() + { + CountingEnumerable source = new(new[] { 1, 2, 3, 4 }); + int? result = source.DefaultIfNotSingle(x => x > 1); Assert.Equal(0, result); + Assert.Equal(3, source.Count); + } + + [Fact] + public void DefaultIfNotSingle_String_ThrowsArgumentNullException_WhenPredicateIsNull() + { + IEnumerable source = new List { "a", "b", "c" }; + ArgumentNullException ex = Assert.Throws(() => source.DefaultIfNotSingle(null!)); + Assert.Equal("predicate", ex.ParamName); + } + + [Fact] + public void DefaultIfNotSingle_ImmutableArray_String_ThrowsArgumentNullException_WhenPredicateIsNull() + { + ImmutableArray source = ImmutableArray.Create("a", "b", "c"); + ArgumentNullException ex = Assert.Throws(() => source.DefaultIfNotSingle(null!)); + Assert.Equal("predicate", ex.ParamName); + } + + [Fact] + public void CountingEnumerable_Count_Resets_OnEachEnumeration() + { + CountingEnumerable source = new CountingEnumerable(new[] { 1, 2, 3 }); + + using (IEnumerator enumerator = source.GetEnumerator()) + { + Assert.Equal(0, source.Count); + Assert.True(enumerator.MoveNext()); + Assert.Equal(1, source.Count); + } + + List items = new List(); + foreach (int item in source) + { + items.Add(item); + } + + Assert.Equal(3, source.Count); + Assert.Equal(new[] { 1, 2, 3 }, items); + } + + [Fact] + public void DefaultIfNotSingle_ImmutableArray_CallsEnumerableExtension() + { + ImmutableArray source = ImmutableArray.Create("a", "b", "c"); + string? result = source.DefaultIfNotSingle(x => string.Equals(x, "b")); + Assert.Equal("b", result); + } + + [Fact] + public void DefaultIfNotSingle_ThrowsArgumentNullException_WhenSourceIsNull() + { + IEnumerable? source = null; + ArgumentNullException ex = Assert.Throws(() => EnumerableExtensions.DefaultIfNotSingle(source!, x => true)); + Assert.Equal("source", ex.ParamName); + } + + [Fact] + public void DefaultIfNotSingle_ThrowsArgumentNullException_WhenPredicateIsNull() + { + IEnumerable source = new[] { "a", "b", "c" }; + ArgumentNullException ex = Assert.Throws(() => EnumerableExtensions.DefaultIfNotSingle(source, null!)); + Assert.Equal("predicate", ex.ParamName); + } + + private sealed class CountingEnumerable(IEnumerable items) : IEnumerable + { + private readonly IEnumerable _items = items; + + /// + /// Gets tracks the number of items enumerated. Resets to 0 every time is called. + /// This means if enumeration is started but not completed, will reset on the next enumeration. + /// This behavior is intentional for test scenarios that need to track enumeration per run. + /// + public int Count { get; private set; } + + public IEnumerator GetEnumerator() + { + // Reset count on every new enumeration. This can cause Count to reset if enumeration is started but not completed. + Count = 0; + foreach (T item in _items) + { + Count++; + yield return item; + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } } diff --git a/tests/Moq.Analyzers.Test/SetExplicitMockBehaviorCodeFixTests.cs b/tests/Moq.Analyzers.Test/SetExplicitMockBehaviorCodeFixTests.cs index a41252423..97ab8e121 100644 --- a/tests/Moq.Analyzers.Test/SetExplicitMockBehaviorCodeFixTests.cs +++ b/tests/Moq.Analyzers.Test/SetExplicitMockBehaviorCodeFixTests.cs @@ -133,4 +133,9 @@ private void Test() await Verifier.VerifyCodeFixAsync(o, f, referenceAssemblyGroup); } + + // The following tests were removed because the early return paths in RegisterCodeFixesAsync + // (e.g., when TryGetEditProperties returns false or nodeToFix is null) cannot be triggered + // via the public analyzer/codefix APIs or test harness. These paths are not testable without + // breaking encapsulation or using unsupported reflection/mocking of Roslyn internals. } diff --git a/tests/Moq.Analyzers.Test/SetStrictMockBehaviorCodeFixTests.cs b/tests/Moq.Analyzers.Test/SetStrictMockBehaviorCodeFixTests.cs index 5ca675e28..3c9d90351 100644 --- a/tests/Moq.Analyzers.Test/SetStrictMockBehaviorCodeFixTests.cs +++ b/tests/Moq.Analyzers.Test/SetStrictMockBehaviorCodeFixTests.cs @@ -133,4 +133,9 @@ private void Test() await Verifier.VerifyCodeFixAsync(o, f, referenceAssemblyGroup); } + + // The following tests were removed because the early return paths in RegisterCodeFixesAsync + // (e.g., when TryGetEditProperties returns false or nodeToFix is null) cannot be triggered + // via the public analyzer/codefix APIs or test harness. These paths are not testable without + // breaking encapsulation or using unsupported reflection/mocking of Roslyn internals. }