Skip to content

Commit 2aa8611

Browse files
authored
feat: add It.Matches(string) (#292)
This PR adds a new string pattern matching capability to Mockolate through the `It.Matches(string)` API. This allows users to match string parameters using either wildcard patterns (with `*` and `?`) or regular expressions. ### Key changes: - Added `It.Matches(string)` method that returns an `IParameterMatches` interface for fluent configuration - Implemented support for wildcard matching (case-sensitive by default) and regex matching with configurable options - Added comprehensive test coverage for both wildcard and regex matching scenarios
1 parent 0a4679d commit 2aa8611

File tree

6 files changed

+208
-0
lines changed

6 files changed

+208
-0
lines changed

Source/Mockolate/It.Matches.cs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
using System;
2+
using System.Text.RegularExpressions;
3+
using Mockolate.Parameters;
4+
5+
namespace Mockolate;
6+
7+
#pragma warning disable S3453 // This class can't be instantiated; make its constructor 'public'.
8+
public partial class It
9+
{
10+
/// <summary>
11+
/// Matches a <see langword="string" /> parameter against the given wildcard <paramref name="pattern" />.
12+
/// </summary>
13+
public static IParameterMatches Matches(string pattern)
14+
=> new MatchesAsWildcardMatch(pattern);
15+
16+
/// <summary>
17+
/// A string parameter that matches against a pattern.
18+
/// </summary>
19+
public interface IParameterMatches : IParameter<string>
20+
{
21+
/// <summary>
22+
/// Ignores casing when matching the pattern.
23+
/// </summary>
24+
IParameterMatches IgnoringCase(bool ignoreCase = true);
25+
26+
/// <summary>
27+
/// Matches the pattern directly as a regular expression.
28+
/// </summary>
29+
IParameterMatches AsRegex(RegexOptions options = RegexOptions.None, TimeSpan? timeout = null);
30+
}
31+
32+
private sealed class MatchesAsWildcardMatch(string pattern) : TypedMatch<string>, IParameterMatches
33+
{
34+
private bool _ignoreCase;
35+
private bool _isRegex;
36+
private Regex? _regex;
37+
private RegexOptions _regexOptions = RegexOptions.None;
38+
private TimeSpan _timeout = Regex.InfiniteMatchTimeout;
39+
40+
/// <inheritdoc cref="IParameterMatches.IgnoringCase(bool)" />
41+
public IParameterMatches IgnoringCase(bool ignoreCase = true)
42+
{
43+
_ignoreCase = ignoreCase;
44+
return this;
45+
}
46+
47+
/// <inheritdoc cref="IParameterMatches.AsRegex(RegexOptions, TimeSpan?)" />
48+
public IParameterMatches AsRegex(RegexOptions options = RegexOptions.None, TimeSpan? timeout = null)
49+
{
50+
_isRegex = true;
51+
_regexOptions = options;
52+
if (timeout is not null)
53+
{
54+
_timeout = timeout.Value;
55+
}
56+
57+
return this;
58+
}
59+
60+
#pragma warning disable S3218
61+
/// <inheritdoc cref="TypedMatch{T}.Matches(T)" />
62+
protected override bool Matches(string value)
63+
{
64+
_regex ??= (_isRegex, _ignoreCase) switch
65+
{
66+
(false, true) => new Regex(WildcardToRegularExpression(pattern),
67+
RegexOptions.Multiline | RegexOptions.IgnoreCase, _timeout),
68+
(false, false) => new Regex(WildcardToRegularExpression(pattern),
69+
RegexOptions.Multiline, _timeout),
70+
(true, true) => new Regex(pattern,
71+
_regexOptions | RegexOptions.IgnoreCase, _timeout),
72+
(true, false) => new Regex(pattern,
73+
_regexOptions, _timeout),
74+
};
75+
return _regex.IsMatch(value);
76+
}
77+
#pragma warning restore S3218
78+
79+
/// <inheritdoc cref="object.ToString()" />
80+
public override string ToString() => $"It.Matches(\"{pattern.Replace("\"", "\\\"")}\")";
81+
82+
private static string WildcardToRegularExpression(string value)
83+
{
84+
string regex = Regex.Escape(value)
85+
.Replace("\\?", ".")
86+
.Replace("\\*", "(?:.|\\n)*");
87+
return $"^{regex}$";
88+
}
89+
}
90+
}
91+
#pragma warning restore S3453 // This class can't be instantiated; make its constructor 'public'.

Tests/Mockolate.Api.Tests/Expected/Mockolate_net10.0.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,17 @@ namespace Mockolate
4949
public static Mockolate.Parameters.IRefParameter<T> IsRef<T>(System.Func<T, bool> predicate, System.Func<T, T> setter, [System.Runtime.CompilerServices.CallerArgumentExpression("predicate")] string doNotPopulateThisValue1 = "", [System.Runtime.CompilerServices.CallerArgumentExpression("setter")] string doNotPopulateThisValue2 = "") { }
5050
public static Mockolate.Parameters.IVerifySpanParameter<T> IsSpan<T>(System.Func<T[], bool> predicate) { }
5151
public static Mockolate.Parameters.IParameter<bool> IsTrue() { }
52+
public static Mockolate.It.IParameterMatches Matches(string pattern) { }
5253
public interface IInRangeParameter<out T> : Mockolate.Parameters.IParameter<T>
5354
{
5455
Mockolate.Parameters.IParameter<T> Exclusive();
5556
Mockolate.Parameters.IParameter<T> Inclusive();
5657
}
58+
public interface IParameterMatches : Mockolate.Parameters.IParameter<string>
59+
{
60+
Mockolate.It.IParameterMatches AsRegex(System.Text.RegularExpressions.RegexOptions options = 0, System.TimeSpan? timeout = default);
61+
Mockolate.It.IParameterMatches IgnoringCase(bool ignoreCase = true);
62+
}
5763
}
5864
public class Match
5965
{

Tests/Mockolate.Api.Tests/Expected/Mockolate_net8.0.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,17 @@ namespace Mockolate
4848
public static Mockolate.Parameters.IRefParameter<T> IsRef<T>(System.Func<T, bool> predicate, System.Func<T, T> setter, [System.Runtime.CompilerServices.CallerArgumentExpression("predicate")] string doNotPopulateThisValue1 = "", [System.Runtime.CompilerServices.CallerArgumentExpression("setter")] string doNotPopulateThisValue2 = "") { }
4949
public static Mockolate.Parameters.IVerifySpanParameter<T> IsSpan<T>(System.Func<T[], bool> predicate) { }
5050
public static Mockolate.Parameters.IParameter<bool> IsTrue() { }
51+
public static Mockolate.It.IParameterMatches Matches(string pattern) { }
5152
public interface IInRangeParameter<out T> : Mockolate.Parameters.IParameter<T>
5253
{
5354
Mockolate.Parameters.IParameter<T> Exclusive();
5455
Mockolate.Parameters.IParameter<T> Inclusive();
5556
}
57+
public interface IParameterMatches : Mockolate.Parameters.IParameter<string>
58+
{
59+
Mockolate.It.IParameterMatches AsRegex(System.Text.RegularExpressions.RegexOptions options = 0, System.TimeSpan? timeout = default);
60+
Mockolate.It.IParameterMatches IgnoringCase(bool ignoreCase = true);
61+
}
5662
}
5763
public class Match
5864
{

Tests/Mockolate.Api.Tests/Expected/Mockolate_netstandard2.0.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,17 @@ namespace Mockolate
4343
public static Mockolate.Parameters.IRefParameter<T> IsRef<T>(System.Func<T, bool> predicate, [System.Runtime.CompilerServices.CallerArgumentExpression("predicate")] string doNotPopulateThisValue = "") { }
4444
public static Mockolate.Parameters.IRefParameter<T> IsRef<T>(System.Func<T, bool> predicate, System.Func<T, T> setter, [System.Runtime.CompilerServices.CallerArgumentExpression("predicate")] string doNotPopulateThisValue1 = "", [System.Runtime.CompilerServices.CallerArgumentExpression("setter")] string doNotPopulateThisValue2 = "") { }
4545
public static Mockolate.Parameters.IParameter<bool> IsTrue() { }
46+
public static Mockolate.It.IParameterMatches Matches(string pattern) { }
4647
public interface IInRangeParameter<out T> : Mockolate.Parameters.IParameter<T>
4748
{
4849
Mockolate.Parameters.IParameter<T> Exclusive();
4950
Mockolate.Parameters.IParameter<T> Inclusive();
5051
}
52+
public interface IParameterMatches : Mockolate.Parameters.IParameter<string>
53+
{
54+
Mockolate.It.IParameterMatches AsRegex(System.Text.RegularExpressions.RegexOptions options = 0, System.TimeSpan? timeout = default);
55+
Mockolate.It.IParameterMatches IgnoringCase(bool ignoreCase = true);
56+
}
5157
}
5258
public class Match
5359
{
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
using System.Text.RegularExpressions;
2+
using Mockolate.Parameters;
3+
4+
namespace Mockolate.Tests;
5+
6+
public sealed partial class MatchTests
7+
{
8+
public sealed class MatchesTests
9+
{
10+
[Theory]
11+
[InlineData("foo", "g[aeiou]+o", 0)]
12+
[InlineData("foo", "F[aeiou]+o", 1)]
13+
[InlineData("foobar", "f[aeiou]*baz", 0)]
14+
[InlineData("foobar", "f[aeiou]*BAR", 1)]
15+
public async Task Matches_AsRegex_IgnoringCase_ShouldMatchRegexCaseInsensitive(
16+
string value, string regex, int expectedCount)
17+
{
18+
IMyServiceWithNullable mock = Mock.Create<IMyServiceWithNullable>();
19+
20+
mock.DoSomethingWithString(value);
21+
22+
await That(mock.VerifyMock.Invoked.DoSomethingWithString(It.Matches(regex).AsRegex().IgnoringCase()))
23+
.Exactly(expectedCount);
24+
}
25+
26+
[Theory]
27+
[InlineData("foo", "F[aeiou]+o", 0)]
28+
[InlineData("foo", "f[aeiou]+o", 1)]
29+
[InlineData("foobar", "f[aeiou]*BAR", 0)]
30+
[InlineData("foobar", "f[aeiou]*bar", 1)]
31+
public async Task Matches_AsRegex_ShouldMatchRegexCaseSensitive(string value, string regex, int expectedCount)
32+
{
33+
IMyServiceWithNullable mock = Mock.Create<IMyServiceWithNullable>();
34+
35+
mock.DoSomethingWithString(value);
36+
37+
await That(mock.VerifyMock.Invoked.DoSomethingWithString(It.Matches(regex).AsRegex()))
38+
.Exactly(expectedCount);
39+
}
40+
41+
[Fact]
42+
public async Task Matches_AsRegex_WithRegexOptions_ShouldUseRegexOption()
43+
{
44+
IMyServiceWithNullable mock = Mock.Create<IMyServiceWithNullable>();
45+
46+
mock.DoSomethingWithString("foo");
47+
48+
await That(mock.VerifyMock.Invoked.DoSomethingWithString(
49+
It.Matches("F[aeiou]+o").AsRegex(RegexOptions.IgnoreCase)))
50+
.Exactly(1);
51+
}
52+
53+
[Theory]
54+
[InlineData("foo", "g?o", 0)]
55+
[InlineData("foo", "F?o", 1)]
56+
[InlineData("foobar", "f*baz", 0)]
57+
[InlineData("foobar", "f*BAR", 1)]
58+
[InlineData("foobar", "f??baz", 0)]
59+
[InlineData("foobar", "f??bar", 1)]
60+
public async Task Matches_IgnoringCase_ShouldMatchWildcardCaseInsensitive(
61+
string value, string wildcard, int expectedCount)
62+
{
63+
IMyServiceWithNullable mock = Mock.Create<IMyServiceWithNullable>();
64+
65+
mock.DoSomethingWithString(value);
66+
67+
await That(mock.VerifyMock.Invoked.DoSomethingWithString(It.Matches(wildcard).IgnoringCase()))
68+
.Exactly(expectedCount);
69+
}
70+
71+
[Theory]
72+
[InlineData("foo", "F?o", 0)]
73+
[InlineData("foo", "f?o", 1)]
74+
[InlineData("foobar", "f*BAR", 0)]
75+
[InlineData("foobar", "f*bar", 1)]
76+
[InlineData("foobar", "f?bar", 0)]
77+
[InlineData("foobar", "f??bar", 1)]
78+
public async Task Matches_ShouldMatchWildcardCaseSensitive(string value, string wildcard, int expectedCount)
79+
{
80+
IMyServiceWithNullable mock = Mock.Create<IMyServiceWithNullable>();
81+
82+
mock.DoSomethingWithString(value);
83+
84+
await That(mock.VerifyMock.Invoked.DoSomethingWithString(It.Matches(wildcard))).Exactly(expectedCount);
85+
}
86+
87+
[Fact]
88+
public async Task ToString_ShouldReturnExpectedValue()
89+
{
90+
IParameter<string> sut = It.Matches("f*\"oo");
91+
string expectedValue = "It.Matches(\"f*\\\"oo\")";
92+
93+
string? result = sut.ToString();
94+
95+
await That(result).IsEqualTo(expectedValue);
96+
}
97+
}
98+
}

Tests/Mockolate.Tests/MatchTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ internal interface IMyServiceWithNullable
8383
void DoSomething(int? value, bool flag);
8484
void DoSomethingWithInt(int value);
8585
void DoSomethingWithLong(int value);
86+
void DoSomethingWithString(string value);
8687
}
8788

8889
internal class AllEqualComparer : IEqualityComparer<int>

0 commit comments

Comments
 (0)