diff --git a/Source/Mockolate/It.IsAny.cs b/Source/Mockolate/It.IsAny.cs index 532ad8a8..d5a65229 100644 --- a/Source/Mockolate/It.IsAny.cs +++ b/Source/Mockolate/It.IsAny.cs @@ -1,3 +1,4 @@ +using System; using Mockolate.Internals; using Mockolate.Parameters; @@ -22,17 +23,28 @@ public partial class It /// The declared type of the parameter. /// A parameter matcher that accepts every value of type . public static IParameterWithCallback IsAny() - => new AnyParameterMatch(); + => AnyParameterMatch.Shared; #if !DEBUG [System.Diagnostics.DebuggerNonUserCode] #endif - private sealed class AnyParameterMatch : TypedMatch + private class AnyParameterMatch : TypedMatch { + internal static readonly AnyParameterMatch Shared = new SharedAnyParameterMatch(); + protected override bool Matches(T value) => true; /// public override string ToString() => $"It.IsAny<{typeof(T).FormatType()}>()"; + +#if !DEBUG + [System.Diagnostics.DebuggerNonUserCode] +#endif + private sealed class SharedAnyParameterMatch : AnyParameterMatch + { + protected override IParameterWithCallback AddCallback(Action callback) + => ((IParameterWithCallback)new AnyParameterMatch()).Do(callback); + } } } #pragma warning restore S3218 // Inner class members should not shadow outer class "static" or type members diff --git a/Source/Mockolate/It.IsFalse.cs b/Source/Mockolate/It.IsFalse.cs index 3c630577..d64b9469 100644 --- a/Source/Mockolate/It.IsFalse.cs +++ b/Source/Mockolate/It.IsFalse.cs @@ -1,3 +1,4 @@ +using System; using Mockolate.Parameters; namespace Mockolate; @@ -14,18 +15,29 @@ public partial class It /// opposite. /// public static IParameterWithCallback IsFalse() - => new FalseParameterMatch(); + => FalseParameterMatch.Shared; #if !DEBUG [System.Diagnostics.DebuggerNonUserCode] #endif - private sealed class FalseParameterMatch : TypedMatch + private class FalseParameterMatch : TypedMatch { + internal static readonly FalseParameterMatch Shared = new SharedFalseParameterMatch(); + /// protected override bool Matches(bool value) => !value; /// public override string ToString() => "It.IsFalse()"; + +#if !DEBUG + [System.Diagnostics.DebuggerNonUserCode] +#endif + private sealed class SharedFalseParameterMatch : FalseParameterMatch + { + protected override IParameterWithCallback AddCallback(Action callback) + => ((IParameterWithCallback)new FalseParameterMatch()).Do(callback); + } } } #pragma warning restore S3218 // Inner class members should not shadow outer class "static" or type members diff --git a/Source/Mockolate/It.IsNotNull.cs b/Source/Mockolate/It.IsNotNull.cs index 7f0e106c..6c924be1 100644 --- a/Source/Mockolate/It.IsNotNull.cs +++ b/Source/Mockolate/It.IsNotNull.cs @@ -1,3 +1,4 @@ +using System; using Mockolate.Internals; using Mockolate.Parameters; @@ -18,13 +19,15 @@ public partial class It /// Optional override for the matcher's rendering, used in failure messages. /// A parameter matcher that accepts every non- argument. public static IParameterWithCallback IsNotNull(string? toString = null) - => new NotNullParameterMatch(toString); + => toString is null ? NotNullParameterMatch.Shared : new NotNullParameterMatch(toString); #if !DEBUG [System.Diagnostics.DebuggerNonUserCode] #endif - private sealed class NotNullParameterMatch(string? toString) : TypedMatch + private class NotNullParameterMatch(string? toString) : TypedMatch { + internal static readonly NotNullParameterMatch Shared = new SharedNotNullParameterMatch(); + /// protected override bool Matches(T value) => value is not null; @@ -33,6 +36,15 @@ private sealed class NotNullParameterMatch(string? toString) : TypedMatch /// public override string ToString() => toString ?? $"It.IsNotNull<{typeof(T).FormatType()}>()"; + +#if !DEBUG + [System.Diagnostics.DebuggerNonUserCode] +#endif + private sealed class SharedNotNullParameterMatch() : NotNullParameterMatch(null) + { + protected override IParameterWithCallback AddCallback(Action callback) + => ((IParameterWithCallback)new NotNullParameterMatch(null)).Do(callback); + } } } #pragma warning restore S3218 // Inner class members should not shadow outer class "static" or type members diff --git a/Source/Mockolate/It.IsNull.cs b/Source/Mockolate/It.IsNull.cs index e3a10d54..8b85bcd4 100644 --- a/Source/Mockolate/It.IsNull.cs +++ b/Source/Mockolate/It.IsNull.cs @@ -1,3 +1,4 @@ +using System; using Mockolate.Internals; using Mockolate.Parameters; @@ -18,18 +19,29 @@ public partial class It /// Optional override for the matcher's rendering, used in failure messages. /// A parameter matcher that only accepts . public static IParameterWithCallback IsNull(string? toString = null) - => new NullParameterMatch(toString); + => toString is null ? NullParameterMatch.Shared : new NullParameterMatch(toString); #if !DEBUG [System.Diagnostics.DebuggerNonUserCode] #endif - private sealed class NullParameterMatch(string? toString) : TypedMatch + private class NullParameterMatch(string? toString) : TypedMatch { + internal static readonly NullParameterMatch Shared = new SharedNullParameterMatch(); + /// protected override bool Matches(T value) => value is null; /// public override string ToString() => toString ?? $"It.IsNull<{typeof(T).FormatType()}>()"; + +#if !DEBUG + [System.Diagnostics.DebuggerNonUserCode] +#endif + private sealed class SharedNullParameterMatch() : NullParameterMatch(null) + { + protected override IParameterWithCallback AddCallback(Action callback) + => ((IParameterWithCallback)new NullParameterMatch(null)).Do(callback); + } } } #pragma warning restore S3218 // Inner class members should not shadow outer class "static" or type members diff --git a/Source/Mockolate/It.IsTrue.cs b/Source/Mockolate/It.IsTrue.cs index 52cdc174..a31ab000 100644 --- a/Source/Mockolate/It.IsTrue.cs +++ b/Source/Mockolate/It.IsTrue.cs @@ -1,3 +1,4 @@ +using System; using Mockolate.Parameters; namespace Mockolate; @@ -14,18 +15,29 @@ public partial class It /// for the opposite. /// public static IParameterWithCallback IsTrue() - => new TrueParameterMatch(); + => TrueParameterMatch.Shared; #if !DEBUG [System.Diagnostics.DebuggerNonUserCode] #endif - private sealed class TrueParameterMatch : TypedMatch + private class TrueParameterMatch : TypedMatch { + internal static readonly TrueParameterMatch Shared = new SharedTrueParameterMatch(); + /// protected override bool Matches(bool value) => value; /// public override string ToString() => "It.IsTrue()"; + +#if !DEBUG + [System.Diagnostics.DebuggerNonUserCode] +#endif + private sealed class SharedTrueParameterMatch : TrueParameterMatch + { + protected override IParameterWithCallback AddCallback(Action callback) + => ((IParameterWithCallback)new TrueParameterMatch()).Do(callback); + } } } #pragma warning restore S3218 // Inner class members should not shadow outer class "static" or type members diff --git a/Source/Mockolate/It.cs b/Source/Mockolate/It.cs index 693a80fc..19147393 100644 --- a/Source/Mockolate/It.cs +++ b/Source/Mockolate/It.cs @@ -52,6 +52,16 @@ private abstract class TypedMatch : IParameterWithCallback, IParameterMatc /// IParameterWithCallback IParameterWithCallback.Do(Action callback) + => AddCallback(callback); + + /// + /// Attaches a to this matcher and returns the matcher to continue the fluent chain. + /// + /// + /// Default: mutates this instance's callback list. Override in cached/shared matcher instances to allocate a fresh + /// mutable copy so the singleton never accumulates per-call callbacks. + /// + protected virtual IParameterWithCallback AddCallback(Action callback) { _callbacks ??= []; _callbacks.Add(callback); diff --git a/Source/Mockolate/ParameterExtensions.cs b/Source/Mockolate/ParameterExtensions.cs index 97eb9a0a..82e5298e 100644 --- a/Source/Mockolate/ParameterExtensions.cs +++ b/Source/Mockolate/ParameterExtensions.cs @@ -22,9 +22,8 @@ public static IParameterWithCallback Monitor(this IParameterWithCallback monitor) { ParameterMonitor parameterMonitor = new(); - parameter.Do(v => parameterMonitor.AddValue(v)); monitor = parameterMonitor; - return parameter; + return parameter.Do(v => parameterMonitor.AddValue(v)); } /// diff --git a/Tests/Mockolate.Tests/ItTests.IsAnyTests.cs b/Tests/Mockolate.Tests/ItTests.IsAnyTests.cs index 75af59e3..d4ab0764 100644 --- a/Tests/Mockolate.Tests/ItTests.IsAnyTests.cs +++ b/Tests/Mockolate.Tests/ItTests.IsAnyTests.cs @@ -6,6 +6,27 @@ public sealed partial class ItTests { public sealed class IsAnyTests { + [Fact] + public async Task CachedMatcher_CallbackFromPriorDo_ShouldNotLeakIntoSubsequentUsage() + { + _ = It.IsAny().Do(_ => throw new InvalidOperationException("callback leaked from prior use")); + + IParameter subsequent = It.IsAny(); + + await That(() => ((IParameterMatch)subsequent).InvokeCallbacks(42)).DoesNotThrow(); + } + + [Fact] + public async Task CachedMatcher_MonitorFromPriorUsage_ShouldNotLeakIntoSubsequentUsage() + { + _ = It.IsAny().Monitor(out IParameterMonitor leakedMonitor); + + IParameter subsequent = It.IsAny(); + ((IParameterMatch)subsequent).InvokeCallbacks(42); + + await That(leakedMonitor.Values).IsEmpty(); + } + [Theory] [InlineData(null)] [InlineData("")] diff --git a/Tests/Mockolate.Tests/ItTests.IsFalseTests.cs b/Tests/Mockolate.Tests/ItTests.IsFalseTests.cs index e77b8ac9..6212c356 100644 --- a/Tests/Mockolate.Tests/ItTests.IsFalseTests.cs +++ b/Tests/Mockolate.Tests/ItTests.IsFalseTests.cs @@ -6,6 +6,16 @@ public sealed partial class ItTests { public sealed class IsFalseTests { + [Fact] + public async Task CachedMatcher_CallbackFromPriorDo_ShouldNotLeakIntoSubsequentUsage() + { + _ = It.IsFalse().Do(_ => throw new InvalidOperationException("callback leaked from prior use")); + + IParameter subsequent = It.IsFalse(); + + await That(() => ((IParameterMatch)subsequent).InvokeCallbacks(false)).DoesNotThrow(); + } + [Theory] [InlineData(false, 1)] [InlineData(true, 0)] diff --git a/Tests/Mockolate.Tests/ItTests.IsNotNullTests.cs b/Tests/Mockolate.Tests/ItTests.IsNotNullTests.cs index 9041e319..55ace76a 100644 --- a/Tests/Mockolate.Tests/ItTests.IsNotNullTests.cs +++ b/Tests/Mockolate.Tests/ItTests.IsNotNullTests.cs @@ -7,6 +7,16 @@ public sealed partial class ItTests { public sealed class IsNotNullTests { + [Fact] + public async Task CachedMatcher_CallbackFromPriorDo_ShouldNotLeakIntoSubsequentUsage() + { + _ = It.IsNotNull().Do(_ => throw new InvalidOperationException("callback leaked from prior use")); + + IParameter subsequent = It.IsNotNull(); + + await That(() => ((IParameterMatch)subsequent).InvokeCallbacks("value")).DoesNotThrow(); + } + [Theory] [InlineData(null, 0)] [InlineData(1, 1)] diff --git a/Tests/Mockolate.Tests/ItTests.IsNullTests.cs b/Tests/Mockolate.Tests/ItTests.IsNullTests.cs index d72d2628..03e1a9f5 100644 --- a/Tests/Mockolate.Tests/ItTests.IsNullTests.cs +++ b/Tests/Mockolate.Tests/ItTests.IsNullTests.cs @@ -7,6 +7,16 @@ public sealed partial class ItTests { public sealed class IsNullTests { + [Fact] + public async Task CachedMatcher_CallbackFromPriorDo_ShouldNotLeakIntoSubsequentUsage() + { + _ = It.IsNull().Do(_ => throw new InvalidOperationException("callback leaked from prior use")); + + IParameter subsequent = It.IsNull(); + + await That(() => ((IParameterMatch)subsequent).InvokeCallbacks(null)).DoesNotThrow(); + } + [Theory] [InlineData(null, 1)] [InlineData(1, 0)] diff --git a/Tests/Mockolate.Tests/ItTests.IsTrueTests.cs b/Tests/Mockolate.Tests/ItTests.IsTrueTests.cs index d87329e1..7b415263 100644 --- a/Tests/Mockolate.Tests/ItTests.IsTrueTests.cs +++ b/Tests/Mockolate.Tests/ItTests.IsTrueTests.cs @@ -6,6 +6,16 @@ public sealed partial class ItTests { public sealed class IsTrueTests { + [Fact] + public async Task CachedMatcher_CallbackFromPriorDo_ShouldNotLeakIntoSubsequentUsage() + { + _ = It.IsTrue().Do(_ => throw new InvalidOperationException("callback leaked from prior use")); + + IParameter subsequent = It.IsTrue(); + + await That(() => ((IParameterMatch)subsequent).InvokeCallbacks(true)).DoesNotThrow(); + } + [Fact] public async Task ToString_ShouldReturnExpectedValue() {